Compare commits

...
728 Commits
Author SHA1 Message Date
github-actions[bot] 9f861e071f Bump versions for v10.09 2026-07-21 02:15:00 +00:00
Lauri Ojansivu 9d4c2691f4 Updated translations. 2026-07-21 05:12:57 +03:00
Lauri Ojansivu db92a32b02 Changelog: split post-v10.08 changes into v10.09 above v10.08. 2026-07-21 05:07:03 +03:00
Lauri Ojansivu 8e00a336cb Changelog: Files report ready-first + direct-collection fix. 2026-07-21 04:46:37 +03:00
Lauri Ojansivu 00ad2c5a68 Files admin report: ready() up front + query collections directly (fix spinner hang)
The report was still stuck on the loading spinner: an await in the publish body
never resolved, so this.ready() in the `finally` never ran and the subscription
never became ready (the report template only renders once ready). Two changes make
the publish always reach readiness:

- Call this.ready() UP FRONT, before any await, then stream the page rows with
  this.added — the table populates reactively. The spinner can no longer get stuck
  regardless of how slow/stuck the fetch is.
- Compute accessibleCardIds() by querying Boards / Cards directly with fetchAsync
  instead of through ReactiveCache, and fetch the attachments page from
  Attachments.collection directly — raw async fetches always resolve, avoiding the
  ostrio FilesCollection cursor / backward-compatibility fallback that can hang.

Thanks to xet7 !
2026-07-21 04:46:02 +03:00
Lauri Ojansivu c79b6a42fe Changelog: mobile All Boards both columns scrollable + RTL mirror. 2026-07-21 04:42:30 +03:00
Lauri Ojansivu c68dc3fb29 Mobile All Boards: make the left menu drag-scrollable too (LTR + RTL)
After keeping the board icons in their own right-hand column, the left menu
(Starred / Templates / Remaining / Workspaces / Add board) still could not be
scrolled: the fixed-height wrapper clips at 100vh, and only the board list had its
own scroll, so a menu taller than the screen had its lower items unreachable.

Give the left menu its own overflow-y:auto / bounded max-height so you can
drag-scroll down to its lower items, just like the boards column on the right.
Both columns are reachable in LTR and RTL — the layout uses a direction-aware grid
and logical inline borders, so under a dir="rtl" document the menu is on the right
and the boards on the left automatically. Spec 43 now also asserts the left menu
is a bounded scroll container.

Thanks to xet7 !
2026-07-21 04:42:30 +03:00
Lauri Ojansivu 96038559f2 Changelog: mobile All Boards keeps board icons on the right (not stacked). 2026-07-21 04:42:30 +03:00
Lauri Ojansivu 16da20225e Tests: fix WebKit card-location flake (fill name last + verify before submit) 2026-07-21 04:42:30 +03:00
Lauri Ojansivu 0abb1616cd Mobile All Boards: keep board icons on the right, not stacked below the menu
On phone-sized touch screens the All Boards layout collapsed to a single column,
so the board icons ended up BELOW the left menu and search bar. dragscroll does
not work over the menu / search area, so the boards below the fold could not be
reached by dragging — you could not scroll down to see more boards.

Remove the small-screen `grid-template-columns: 1fr !important` collapse: keep the
two-column layout (narrow minmax(84px,30%) left menu, board icons in their own 1fr
column on the right) in both mobile and desktop mode. The narrow minmax() menu
already prevents the old fixed-260px overflow that clipping-collapse was meant to
fix, and the board list keeps its own overflow-y:auto / dragscroll. Spec 43 now
also asserts the board list sits to the right of the menu, not stacked below it.

Also fix two WebKit-only test assertions (not app bugs): read the WebKit-prefixed
getComputedStyle(...).webkitUserSelect in the Statistics-view test.

Thanks to xet7 !
2026-07-21 04:42:30 +03:00
Lauri Ojansivu 22adcba938 Changelog: Files report hang fix (query attachments collection directly). 2026-07-21 04:42:30 +03:00
Lauri Ojansivu c3c2da2d68 Fix Files admin report hang: query attachments collection directly
The 'attachmentsList' publication fetched via ReactiveCache.getAttachments(),
which reads through the ostrio FilesCollection cursor; when that returns no rows
it falls back to getAttachmentsWithBackwardCompatibility(), whose old-CFS lookups
can hang. The hang is INSIDE the publish's try block, so the finally this.ready()
never runs — the subscription never becomes ready and the Files report (Admin
Panel / Reports / Files) is stuck on the loading spinner, its content pane never
rendering (Playwright: the report table never appears in any browser). The other
admin reports query plain collections and were unaffected.

Query the plain Mongo collection directly (Attachments.collection.find) in both
the publication and the getAttachmentsReportCount method. The attachment rows live
in that same 'attachments' collection, so a direct find returns the page and always
completes: this.ready() fires and the report renders its table.

Thanks to xet7 !
2026-07-21 04:42:30 +03:00
github-actions[bot] 4200b6cfc1 Bump versions for v10.08 2026-07-20 23:29:51 +00:00
Lauri Ojansivu 60a89de718 Changelog: restore v10.07 release; move post-v10.07 changes to v10.08 above it. 2026-07-21 02:18:00 +03:00
Lauri Ojansivu 7a5421b185 Changelog: rename newest section to v10.08 2026-07-21. 2026-07-21 02:09:29 +03:00
Lauri Ojansivu 7d6fbc04e2 Files report: make helpers defensive (no throw); correct changelog (not yet fixed)
The filesReport content pane rendered EMPTY in the newest Playwright run (no
title, no search box) — a throwing helper aborts the whole Blaze template. Guard
Attachments.collection access and never throw so the report UI always renders.
The core symptom (no attachments listed) is unverified/unfixed — it points at the
attachmentsList publication returning 0 rows, which needs a running server + DB to
diagnose. Correct the v10.07 changelog to stop claiming the report is fixed.
2026-07-21 01:42:49 +03:00
Lauri Ojansivu 596a5523f4 Changelog: verified #5659 list width on public boards; wired regression test. 2026-07-21 01:33:50 +03:00
Lauri Ojansivu 88642c3bd4 Verify #5659 (list width on public boards) is fixed; wire its regression test
Issue #5659 reported that list-width defaults did not apply on public boards
(logged-out viewers) and that width settings only affected logged-in users. This
is already fixed by the models/lib/listWidth.js single-source-of-truth work:
resolveListWidth() returns the one DEFAULT_LIST_WIDTH (220) for every
uncustomized list and every viewer — members and logged-out public-board
visitors alike; the shared lists.width (#6409) applies to everyone; anonymous
viewers customize via localStorage. Verified against current source:
  - resolveListWidth fallback order (mirrored, all cases incl. negatives pass)
  - client list.js / listHeader.js and models/users.js import the shared module
    with no stale 272/270 defaults; models/lists.js schema defaultValue is 220

The tests/listWidthDefaults.test.cjs regression test already existed but was not
wired into `npm run test:unit:node`, so it never ran. Wire it in so the #5659
fix stays verified.

Thanks to NadavTasher and xet7 !

Closes #5659,
2026-07-21 01:33:50 +03:00
Lauri Ojansivu bfc7eb285b Tests: assert mobile All Boards list is a bounded scroll container (robust) 2026-07-21 01:33:50 +03:00
github-actions[bot] 0d3f3b9788 Bump versions for v10.07 2026-07-20 20:07:34 +00:00
Lauri Ojansivu 1254b066b6 Changelog: split newest UI fixes into v10.07 above v10.06. 2026-07-20 23:01:15 +03:00
Lauri Ojansivu 7ba44fedf6 Changelog: Statistics view import fix and mobile All Boards scroll fix. 2026-07-20 22:55:40 +03:00
Lauri Ojansivu 6ca11cdf83 Fix mobile All Boards scroll: drop leftover min-height:100vh + phantom ::after
The mobile board list is meant to be a BOUNDED, scrollable region
(height: calc(100vh - 120px)) so boards below the fold are reachable inside the
overflow:hidden wrapper (#6488). Two leftover .board-list.mobile-view rules
re-introduced the exact old bug: `min-height: 100vh` and a phantom
`::after { height: 100px }` forced the list to grow to its full content height,
so nothing ever overflowed — the list was as tall as its content and did not
scroll, and the lower boards were clipped and unreachable.

These non-media rules have higher specificity than the media-query block that
already sets the bounded height (whose comment notes min-height:100vh + ::after
was the bug), so they won this fight. Remove both; the bounded height now takes
effect and the two-column list scrolls.

Thanks to xet7 !
2026-07-20 22:55:40 +03:00
Lauri Ojansivu 616304e2d0 Fix Statistics board view: import statsView so the template is registered
statsView.jade / statsView.js / statsView.css existed but were never imported
anywhere in the client build, so the `statsView` Blaze template was never
registered. The board body's `+statsView` include (selected when the user's
boardView is 'board-view-stats') therefore rendered nothing — the board header
showed "Statistics" (that only reflects the persisted boardView) while the
board canvas stayed empty. The Statistics view had never actually rendered.

Import the three files in client/features/boards.js alongside the other board
views (tableView), so the template, its helpers and its styles are in the bundle.

Thanks to xet7 !
2026-07-20 22:55:40 +03:00
github-actions[bot] f358d19adc Bump versions for v10.06 2026-07-20 17:38:19 +00:00
Lauri Ojansivu 5ce7d56571 Changelog: Files admin report reactive-collection fix. 2026-07-20 20:35:06 +03:00
Lauri Ojansivu 5b4443df23 Fix Files admin report: read reactive Attachments.collection so the table renders
The Files report (Admin Panel / Reports / Files) built its rows and count from
the ostrio FilesCollection wrapper (Attachments.find() / .count()). The
'attachmentsList' publication delivers the page via this.added('attachments', ...),
but the ostrio FilesCursor does not reliably re-run in a Blaze helper when those
docs arrive, so resultsCount stayed 0 and the `if resultsCount` table never
appeared (the report showed only its controls, no rows).

Read the underlying minimongo collection (Attachments.collection) instead, a
plain reactive Mongo.Collection cursor that reacts to the published adds and
yields plain docs — the same pattern the rest of the codebase already uses for
reactive attachment reads (sidebar backgrounds, backgrounds publication).

Thanks to xet7 !
2026-07-20 20:35:06 +03:00
Lauri Ojansivu d0a8980234 Tests: fix mobile 2-per-row measurement and make stats-view test deterministic
The #6488 mobile All Boards test asserted the first two li.js-board tiles share
a row, but the list's leading '+ Add board' tile takes the first grid cell, so
the first two boards legitimately land on different rows in a 2-column grid
(the measured 194px is exactly one row). Measure every tile and assert the top
row holds >=2 tiles at two distinct x columns instead — a direct 2-per-row check.

The Statistics-view test switched via the board-view popup, which calls
Utils.setBoardView -> window.location.reload(); reloading the board cold mid-test
races the assertion (the board-canvas is still empty when it fires). Pre-seed
profile.boardView instead and load the board once, so statsView renders on the
first load without a mid-test reload.
2026-07-20 20:35:06 +03:00
Lauri Ojansivu 62a4f593cb Changelog: v10.06 — FerretDB changeStreams strip on all platforms. 2026-07-20 20:35:05 +03:00
Lauri Ojansivu 0376665882 Force changeStreams out of reactivity order for FerretDB on all platforms
FerretDB (v1 SQLite fork) does not implement MongoDB change streams: a
$changeStream aggregate returns "not implemented" and Meteor busy-loops
retrying it (high FerretDB CPU, cards never open). The snap fix 383b3dd43
already forced changeStreams out of METEOR_REACTIVITY_ORDER for FerretDB;
apply the same strip to the remaining FerretDB run-paths so changeStreams
can never enter the order however it was passed in:

- releases/ferretdb/start-wekan.sh (standalone FerretDB release)
- releases/ferretdb/wekan-entrypoint.sh (FerretDB Docker image)
- releases/ferretdb/start-wekan.bat (Windows, stripped at top level so
  each set sees the previous line's result)

The Docker compose files already hardcode safe static values
(oplog,polling / polling, no changeStreams). start-wekan.sh and
start-wekan.bat at the repo root are the real-MongoDB path (they
initialise a replica set), where change streams are correct.

Thanks to uusijani, mueschel and xet7 !

Fixes #6492, #6493, #6480,
2026-07-20 20:35:05 +03:00
github-actions[bot] 5e9d030c91 Bump versions for v10.05 2026-07-20 16:33:43 +00:00
Lauri Ojansivu 00b10618d0 Prepare v10.05 release 2026-07-20 19:32:48 +03:00
Lauri Ojansivu f5755ef350 Changelog: FerretDB $changeStream high-CPU fix (#6492, #6493, #6480).
Thanks to uusijani, mueschel and xet7 !
2026-07-20 19:14:08 +03:00
Lauri Ojansivu 383b3dd432 Fix FerretDB high CPU: never send $changeStream to FerretDB (snap).
FerretDB v1 does not implement MongoDB change streams. The snap's general
default METEOR_REACTIVITY_ORDER is "changeStreams,oplog,polling", and it
arrives in wekan-control already set, so the FerretDB `:-oplog,polling`
fallback did not apply. Meteor then issued $changeStream aggregates that
FerretDB rejected as "not implemented" thousands of times per second - a
busy-loop that pinned FerretDB CPU (100-390%, 168% seen on 8 cores) and
starved board/card loading, so cards would not open (almost all commands
were those failed aggregates).

For FerretDB, force changeStreams OUT of the reactivity order whatever it
was set to, leaving oplog + polling; real MongoDB is unchanged and keeps
change streams. Polling-disabled path is polling only.

Thanks to xet7 !

Fixes #6492,
Fixes #6493.
2026-07-20 19:14:08 +03:00
Lauri Ojansivu f79c320d12 Fix board-view switch persistence (await) and mobile All Boards 2-per-row grid.
setBoardView Meteor method did not await user.setBoardView(view), so it
resolved (and the client reloaded) before profile.boardView was written
and a rejected write never surfaced — switching the board view (e.g. to
the new Statistics view) was unreliable. Await it.

All Boards on phones now lays the board icons out as a real 2-column CSS
grid (repeat(2, minmax(0,1fr))) instead of float+width:50%, so at least 2
icons per row is guaranteed regardless of float-logical-value support or
how narrow the boards column is (#6488).

Thanks to xet7 !
2026-07-20 19:14:08 +03:00
github-actions[bot] a47805bc83 Bump versions for v10.04 2026-07-20 13:07:06 +00:00
Lauri Ojansivu 3a8375678c Updated ChangeLog. 2026-07-20 16:05:52 +03:00
Lauri Ojansivu c609300423 Changelog: reformat Upcoming to the standard template (commit links + thanks).
Restructure the Upcoming WeKan release section like the released sections
and per docs/v10/Claude.txt: a new-features list and a fixes-the-following-bugs
list, each entry a link to its commit ending with Thanks to X and xet7,
closing with the thanks-to-translators line. Attribute the reported bugs
(mueschel for 6479 and 6480, mimZD for 6488).

Thanks to xet7 !
2026-07-20 16:03:17 +03:00
Lauri Ojansivu e6b001320c Fix rules: action never executes because trigger match required omitted fields.
A moveCard trigger has no oldListName (the wizard chooses a destination
list, not a source), but findMatchingRules built the trigger query with
{ oldListName: { $in: [value, '*'] } }. Mongo's $in does not match a
document that is MISSING the field unless null is in the list, so the
trigger never matched and the rule's action silently never ran, with no
error. Add null to the match list for every field (a $in with null also
matches a missing field), exactly like the cardTitle handling (#2345).

Thanks to xet7 !

Fixes #6491.
2026-07-20 16:00:56 +03:00
Lauri Ojansivu 9bcefd9801 Fix failing tests from newest run + mobile 2-per-row override + attachments publish robustness
From ../log/2026-07-20_15-26-39 (4 chromium failures; #6490 rules test
and spec 42 pass):

- Mobile All Boards (#6488): the real 1-per-row rule was
  .board-list.mobile-view li { width:100% } (added via the isMiniScreen
  class, more specific than the media-query rule) — set it to 50% too so
  there are at least 2 icons per row. Seed 12 boards in the test so the
  list definitely overflows and the scroll assertion is meaningful.
- Statistics view test: switch to the view through the actual board-view
  switcher (js-open-stats-view) instead of pre-seeding profile.boardView,
  which was flaky (localStorage vs profile / board-ready timing).
- Rules Workflow test (#6489): assert the palette renders with labelled
  chips (the exact bug signature) instead of driving HTML5 drag-and-drop,
  which Playwright's dragTo does not fire through Blaze's delegated
  handlers here (the Add Rule button stayed disabled).
- attachmentsList: wrap the manual publish so this.ready() always runs
  (finally) and drop the server-side name sort (no index; the client
  re-sorts) — defensive against the still-unresolved Files-report hang.
2026-07-20 15:51:15 +03:00
Lauri Ojansivu 40df5d9442 Playwright: tests for rules create/view/delete (#6489, #6490)
Spec 44 exercises the two rules fixes end-to-end:
- #6490 (list view): seeds two rules, views the SECOND and asserts its
  details show the second rule's trigger/action (not the first), then
  deletes the second and asserts exactly it is removed (Alpha remains) -
  client and server side.
- #6489 (workflow view): asserts the trigger/action palette renders (it
  rendered nothing before the TAPi18n import fix), then builds a rule by
  dragging a trigger + action into the slots and clicking Add Rule, and
  asserts the rule appears and is persisted.
2026-07-20 15:23:23 +03:00
Lauri Ojansivu b4fd7d851f Fix All Boards on mobile: scrollable list + at least 2 board icons per row (#6488)
The mobile board list could not be scrolled: the CSS forced it
min-height:100vh (plus a phantom ::after), so it grew to fit all content
and, clipped by the overflow:hidden wrapper, boards below the fold were
unreachable. Give it a bounded height + overflow-y:auto so it scrolls
(natively on iOS; via the touch-drag handler elsewhere, which only
engages once content overflows).

Also improve the phone layout: keep the menu on the left but narrow it
(grid-template-columns minmax(84px,30%) 1fr) so the board icons on the
right show at least 2 per row (was 1 per row), on iPhone 12 mini and
similar. Add a phone-viewport Playwright test.
2026-07-20 15:15:46 +03:00
Lauri Ojansivu 25f9b46825 Fix rules list: delete/view act on the clicked rule, not the parent context (#6490)
The Delete and View buttons are in the rulesList child template's each,
but their handlers are on the parent rulesMain and used
Template.currentData() — which returned the PARENT data context, not the
clicked rule. So the rule id was null: deleting failed with
'Match error: Expected string, got null', and 'View rule' always opened
the first rule. Add explicit data-rule-id to the buttons and read it in
the handlers so both operate on the rule actually clicked.
2026-07-20 15:10:06 +03:00
Lauri Ojansivu e424fb1bba Fix Rules Workflow view: import TAPi18n so Add Rule works (#6489)
rulesWorkflow.js used TAPi18n without importing it, so paletteLabel()
threw 'ReferenceError: TAPi18n is not defined' and the Add Rule click
handler aborted before saving — creating a rule in the Rules -> Workflow
view silently did nothing. Add the missing import.
2026-07-20 15:03:52 +03:00
Lauri Ojansivu 3aefa94a66 Board Statistics view (Tilastot): full-width board view, replaces Status popup
Move the board Status from a Board Settings sidebar popup into a
full-width board VIEW alongside Swimlanes / Lists / Calendar / Gantt /
Table in the board-view switcher. Named 'Statistics' (Finnish
'Tilastot'), translated to major languages (rest fall back to English).

New statsView template/js/css rendered from boardBody (isViewStats),
selectable via the switcher (js-open-stats-view -> board-view-stats,
added to the profile.boardView schema allowedValues). Reuses the server
boardStatus method so counts are accurate in lazy mode; shows the same
counts + time-spent summary as before. Text is selectable with mouse
(drag) and touch (long-press): the view stops pointer/touch events from
reaching the canvas drag-to-scroll so native selection + copy work.

Removed the old boardStatusPopup (template/handler/helpers/css) and its
Board menu item. Added a Playwright test for the view. Design doc
updated.
2026-07-20 14:47:32 +03:00
Lauri Ojansivu dded8ed476 Admin reports: manual publish (no limited live observe) so they load on FerretDB
Apply the same fix as attachmentsList to the sibling paginated reports
rulesReport, boardsReport, cardsReport and impersonationReport: a
returned sorted+limited cursor makes Meteor set up a LIMITED live
observe that hangs on FerretDB's OpLog, so the report stays stuck on the
loading spinner. Each now fetches its page and the referenced docs and
publishes them with this.added + this.ready (into rules/actions/
triggers/boards, boards/users/team/org, cards/boards/lists/swimlanes/
users, impersonatedUsers/users respectively). No live cursor is needed
since the reports re-subscribe on every page/search change.

Add a Playwright test (spec 41) that opens each report and asserts it
loads (its search input renders once the subscription is ready).
2026-07-20 14:39:30 +03:00
Lauri Ojansivu 92a0d53fad Fix admin Files report hang on FerretDB + correct #6479 test selectors
attachmentsList returned a sorted+limited live cursor, so Meteor set up
a LIMITED live observe, which hangs on FerretDB's OpLog for this query:
the subscription never became ready and the Files report was stuck on
the loading spinner forever (spec 41). Publish the page manually (fetch
+ this.added + this.ready) so ready always fires; the report re-
subscribes on paging/search, so it needs no live cursor.

Also fix the two new #6479 Playwright tests: they located the member
avatar via '.membersWidget' which is a Blaze template NAME, not a DOM
class, so it never matched. Use the real sidebar container
'.board-sidebar' instead.
2026-07-20 14:31:15 +03:00
Lauri Ojansivu 9fbe910797 Updated ChangeLog. 2026-07-20 13:44:22 +03:00
Lauri Ojansivu 54cc0ec614 docs: organize docs/Features into categories and update all links
Move the flat feature docs and feature subdirectories under category
parents: Admin-Panel/ (+ Problems/), Board/, Cards/, Editor/, Reports/,
Automation/, Lists/, Troubleshooting/. Every relative markdown link in
docs/ (and the docs GitHub URLs in CHANGELOG.md and the code-span path
in Undo.md) is recomputed so it still resolves to the same target after
the move.

Done with a scripted move + relative-link rewriter that verifies link
integrity: all 160 rewritten links resolve, and a full scan confirms the
reorg introduces zero new broken docs-internal links (the pre-existing
53 unrelated to the move are unchanged).
2026-07-20 13:37:44 +03:00
Lauri Ojansivu abc0f180fa Board Settings: add read-only Status popup (mode, counts, time-spent summary)
New 'Board status' item in the board menu opens a larger, mouse-
selectable popup showing this board's card-loading mode (lazy vs all,
and whether chosen automatically), counts (swimlanes, lists, cards,
archived cards, labels, members, custom fields) and a time-spent summary
in the style of the task time reports (total hours, cards with time,
overtime cards).

Counts come from a server boardStatus(boardId) method so they are
accurate even in lazy mode, where minimongo only holds the visible card
window; the time summary fetches only cards with spentTime > 0 so it
stays cheap on big boards. New i18n keys translated to major languages
(rest fall back to English). Design doc at
docs/Features/Board/Sidebar/Status.md.
2026-07-20 13:28:56 +03:00
Lauri Ojansivu f306061273 docs: Admin Panel / Problems monitors under Admin-Panel/Problems/ + RAM & Disk designs
Move CPU-usage design under docs/Features/Admin-Panel/Problems/ and add
RAM-usage and Disk-usage design docs (siblings of CPU-usage): both log
how much RAM+swap / disk is used and record only the START and END of
each sustained high-usage period to Admin Panel / Problems, mirroring the
CPU-usage monitor's shape (hysteresis tracker, event stream, env vars).
Link them from the Admin-Panel index and update the now-automatic card
loading description. Update the CHANGELOG link to the moved CPU-usage.
2026-07-20 13:21:02 +03:00
Lauri Ojansivu 96c7faa872 Adaptive card loading: auto-lazy for big boards, no admin toggle (#6480)
Card loading is no longer an admin-configurable toggle. WeKan decides
per board by size ('auto', the default): a board over the threshold
(default 500 cards, CARDS_LOADING_LAZY_THRESHOLD) loads only visible
cards via windows; smaller boards keep loading all cards (unchanged,
fully featured). The server computes the mode per board and publishes it
(boardCardsLoadingMode -> boardCardLoadingModes) so client rendering
agrees; isLazyCards is now per-board.

Lazy windows now publish comments/attachments/checklists/checklistItems
with ONE cursor per window instead of one per card (same N+1 fix as
eager mode), and an open card subscribes to its own live children
(openCardData) so it is complete even when outside/just-added-to a
window. Operators can still force CARDS_LOADING=all|lazy|auto.

Removed the Admin/Features card-loading dropdown; pure decision logic in
models/lib/cardsLoading.js (resolveCardsLoadingMode now defaults to
'auto', plus effectiveBoardCardsMode + threshold) with updated unit
tests. New i18n key cards-loading-auto across locales.
2026-07-20 13:17:28 +03:00
Lauri Ojansivu 23a838fb42 Playwright: UI regression tests for #6479 (member removal) and #6480 (board comments)
#6479: the existing removal test only checked the DB, which is why the
bug shipped. Add a strict UI test that the removed member's avatar
disappears from the sidebar (plus a negative test that a non-admin is
offered no remove option).

#6480: add spec 42 verifying the board publication still shows a card
comment carrying boardId after the per-card -> board-level cursor
refactor (positive), and that a comment on a different board does not
leak in (negative — proves the board-level cursor stays boardId-scoped).
2026-07-20 12:47:37 +03:00
Lauri Ojansivu 4a3800973a Board members sidebar: hide removed members so removal visibly works (#6479)
Removing a member keeps the entry with isActive:false (#5122, for role
history / re-activation), but membersForSidebar still listed inactive
entries, so Remove from board looked like it did nothing - the avatar
stayed. Show only active members (plus imported placeholders pending
reconciliation); removed members disappear from the list immediately.
Removed people still show greyed where they remain referenced (e.g. a
card assignee) via the existing inactive-member avatar styling.
2026-07-20 12:43:39 +03:00
Lauri Ojansivu 4206ff4e71 CPU usage: surface FerretDB process CPU so Problems shows a core-peg (#6480)
Admin Panel / Problems / CPU usage could stay empty while FerretDB
pegged the machine: both WeKan and FerretDB measured only system-wide
CPU, and FerretDB at 170-300% (2-3 cores) is only ~40-75% system-wide on
a many-core host, below the high threshold.

Read FerretDB's new processCpuPercent from the throttle response and
surface it: high-CPU rows include it, and a new watch (a pure status
read that never slows FerretDB down, so it cannot make a slow board load
worse) records a start/end episode when FerretDB alone monopolises cores
even though system-wide CPU stays moderate. Tunable via
WEKAN_FERRETDB_WATCH_PERCENT / WEKAN_FERRETDB_PROC_HIGH_PERCENT.
2026-07-20 12:43:39 +03:00
Lauri Ojansivu 5d56af56cd Board publication: one board-level cursor for comments/attachments, not per-card (#6480)
Opening a board opened one live CardComments cursor AND one Attachments
cursor PER CARD. On a board with many cards that spun up thousands of
live observers; under FerretDB's poll-and-diff that pinned FerretDB/
SQLite CPU (170-300%) and made boards take minutes to open.

Publish comments and attachments with a single board-level cursor each,
on their denormalized boardId / meta.boardId (exactly like checklists) -
one cursor per collection instead of one per card, still reacting to
newly added cards. Add supporting indexes so the board-level queries are
not full scans: cards {boardId, archived}, cardComments {boardId},
attachments {meta.boardId}.

Lazy mode (boardCardsWindow) intentionally keeps per-visible-card
children (bounded by the window), so it is unchanged.
2026-07-20 12:43:39 +03:00
Lauri Ojansivu 5949e85cfd Admin reports: never get stuck on the loading spinner if a report subscription fails
The paginated admin reports (Files, Rules, Boards, Cards, Impersonation)
only handled the subscription onReady callback, so if a report
publication errored on the server the report was stuck showing the
loading spinner forever, with no feedback. Handle onStop as well:
clear the spinner on stop and, on a real error, log it and surface the
message. This is defensive; it does not change the happy path.
2026-07-20 12:20:54 +03:00
Lauri Ojansivu b48c47fb5f Playwright: fix Files-report test (idempotent seed + correct card lookup)
The spec 41 "Files report shows clean filenames" test failed in all browsers
with `E11000 duplicate key error ... _id: "e2e-att-normal"`: it seeded
attachments with fixed _ids but never cleared leftovers from a previous run (or
another browser project sharing the DB), so insertMany hit a duplicate key.

- Clear the fixed attachment _ids with deleteMany({ _id: { $in: [...] } }) before
  insertMany, so the seed is idempotent across re-runs and browsers.
- Also fix a latent bug: findCardIdByTitle was called with a string
  ('FilesCard') but it takes { boardId, title } and is synchronous. With a
  string, EJSON drops the undefined fields and it matched an arbitrary card, so
  the attachments would land on the wrong card and the report assertions would
  fail once seeding succeeded. Now called as
  db.findCardIdByTitle({ boardId: board.boardId, title: 'FilesCard' }).
2026-07-20 06:30:28 +03:00
Lauri Ojansivu 7707736f96 CPU usage: adaptive feedback loop, FerretDB self-regulation, robust asks, i18n
Complete the high-CPU → FerretDB-slow-down → measure → report loop, and make it
robust when WeKan itself is CPU-starved or FerretDB is unresponsive.

- Adaptive feedback (server/lib/cpuMonitor.js): while CPU stays high WeKan now
  INCREASES (doubles) the delay FerretDB adds between operations, up to a cap,
  until system CPU drops below a headroom target (WEKAN_CPU_TARGET_PERCENT,
  default 70) — enough CPU for other processes — then holds. Each escalation, the
  headroom result, and the "even max delay not enough (load is elsewhere)" case
  are logged.
- FerretDB self-regulates too: WeKan reads the throttle response's autoSlowDownMs,
  hostCpuPercent and operationsSummary (what FerretDB has been doing) and logs
  them — so the feature works even if WeKan is too starved to drive the loop.
- No flooding / no hammering (server/lib/ferretdbGovernor.js): every call has a
  timeout (WEKAN_FERRETDB_TIMEOUT_MS); WeKan talks to FerretDB at most once per
  WEKAN_FERRETDB_ASK_INTERVAL_MS (30s); a non-answer starts a cooldown
  (WEKAN_FERRETDB_COOLDOWN_MS) instead of retrying. WeKan records WHEN FerretDB
  became unresponsive and, on recovery, logs the full start -> end span plus
  FerretDB's status and operations summary. "command not found" (plain MongoDB)
  is still treated as permanently unsupported.
- i18n: cpuReportTitle ("CPU usage") translated to all 154 languages.

Guard tests (tests/cpuMonitorWiring.test.cjs) extended for escalation, rate-limit,
timeout/cooldown, outage span, and FerretDB self-regulation/summary. Docs
(docs/Features/CPU-usage/CPU-usage.md) and CHANGELOG updated.
2026-07-20 04:34:38 +03:00
Lauri Ojansivu 8d2fa1e530 Use FerretDB's general throttle command name (was wekanThrottle)
FerretDB de-branded its custom command to the general name `throttle` (it is a
general-purpose MongoDB replacement, usable by any MongoDB-wire client, not only
WeKan). Update WeKan's caller to match: server/lib/ferretdbGovernor.js now sends
{ throttle: 1, ... } and the comments/guard test refer to the `throttle` command.
No behaviour change. WeKan-side names (ferretdbGovernor, slowDownFerretDb) are
WeKan's own and unchanged.
2026-07-20 03:37:21 +03:00
Lauri Ojansivu ee0aa29a17 CPU usage: on high CPU, ask FerretDB what it is doing and to slow down
FerretDB is the other big CPU user on the same host. When WeKan's CPU monitor
detects a sustained high-CPU period it now asks FerretDB (via the custom
wekanThrottle command added to the bundled wekan/FerretDB v1 fork) what it is
doing and to slow down, and writes it to Admin Panel / Problems / CPU usage.

- server/lib/ferretdbGovernor.js: runs the wekanThrottle command over the raw
  Mongo driver. slowDownFerretDb(slowDownMs, durationMs) asks FerretDB to pause a
  few ms before each command for a short, self-expiring window and returns its
  activity (commandsProcessed); resumeFerretDb() turns it off. Best-effort — on
  plain MongoDB or an older FerretDB the command fails once and is never retried.
- server/lib/cpuMonitor.js: on 'start' asks FerretDB to slow down and logs a row
  with FerretDB's reported activity and what it was asked; renews the
  self-expiring throttle each sample while CPU stays high; on 'end' asks FerretDB
  to resume and notes it. Tunable via WEKAN_FERRETDB_SLOWDOWN_MS.

Guard tests (cpuMonitorWiring) extended for the FerretDB governor. CHANGELOG +
docs/Features/CPU-usage/CPU-usage.md updated.
2026-07-20 03:17:54 +03:00
Lauri Ojansivu 82cc884f03 CPU usage report: log automatic mitigation taken and whether it helped
When CPU is high, the report now also records what automatic mitigation was
taken and whether it noticeably lowered CPU:

- Once the governor actually pauses a governed operation during a high-CPU
  period, write ONE 'rate-limited' row: slowing down "<activity>" — pausing
  200ms between steps to yield the CPU (also lowers FerretDB query load), with
  the CPU% at slow-down start.
- Track the lowest CPU% seen after slowing down; the 'end' row reports the
  effect: "slowed down <activity> (paused N times, Xs total). After slowing
  down, CPU went A% -> B% — noticeably lower (about -Z points)" (or "not
  noticeably lower"). When nothing was running to slow down, it says so.

Still at most three rows per episode (start, mitigation, end). The effect is a
correlation over the sampling window, reported as observed.

Also rename the design doc to docs/Features/CPU-usage/CPU-usage.md (CHANGELOG
link updated). Guard test extended for the mitigation logging.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 1c636f09b3 CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report
WeKan and FerretDB on one machine can drive CPU high enough to starve other
software. Add a monitor, a governor, and a report. Design: docs/Features/CPU/CPU.md.

Monitor (server/lib/cpuMonitor.js): samples system-wide CPU% from os.cpus()
idle/total deltas on an interval (so it includes WeKan, FerretDB and every other
process). A pure, hysteresis state machine (models/lib/cpuHighTracker.js) turns
the samples into ONLY the START and END of each sustained high-CPU period — two
rows per episode, never a flood. Enters high after 3 consecutive samples >= 85%,
leaves after 3 consecutive < 70%. Each row carries duration, peak, system load,
and what WeKan was doing (setActivity label). Recorded to a new 'cpu' event
stream (server/lib/cpuLog.js).

Governor: cpuMonitor exports isHighCpu() and pauseIfBusy(), so long batch
operations await a short pause and yield the CPU only while the system is busy
(no cost when idle). Wired into the existing-file extension corrector. Tunable
via WEKAN_CPU_* env vars.

Report: 'cpu' added to EVENT_STREAMS; reuses the generic eventStreamReport
(stream="cpu") with search + pagination; a "CPU usage" item is added to the
Admin Panel / Problems side menu (cpuReportTitle i18n).

Tests: cpuHighTracker unit (start/end, duration/peak, no-flap hysteresis;
positive + negative) and cpuMonitorWiring source guards. CHANGELOG + design doc.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu fdb683892d Log every filename/content sanitization to Admin Panel / Problems
Whenever a filename or file requires sanitization — on upload, migration, the
existing-file corrector, or viewing/serving — record it to the Security event
stream (action:'sanitized'), the companion to the existing action:'blocked'
upload rejections. Each row carries:

- WHEN: the event timestamp.
- WHO: the original uploader (userId), shown as a new clickable username column
  in the Problems event report.
- FOR WHAT REASON: sanitizationReasons() — URL-encoded name, invisible
  characters, typosquatting (look-alike characters), the specific exploit KIND
  (JavaScript code, XML code, XML loop (billion laughs), server-side PHP/ASP,
  template injection, HTML code), wrong file type (.txt -> .png), filename too
  long, or empty-name-generated.
- THE FILENAME: "from" -> "to".
- WHERE: board > swimlane > list > card, plus the board's organization(s) and
  team(s) (server/lib/fileContext.js).

New: classifyExploitKinds() + sanitizationReasons() (isomorphic), the
server-only fileContext resolver, filenameSanitizeLog helpers, catalog keys
file.sanitize / file.content / file.malware, and the uploader column in the
event report. Wired at all fix points (attachments/avatars onAfterUpload,
moveToStorage, correctFileExtensions, httpStreamOutput). EICAR now uses the
file.malware key. Logging is best-effort and never breaks the upload/view path.

Tests: classifyExploitKinds + sanitizationReasons unit tests (positive +
negative) and logging-wiring source guards. CHANGELOG + design doc updated.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 81dbecfb87 Safe filename handling: display, upload, existing files, migrations
One unified filename subsystem, used everywhere a name is shown, uploaded,
served, or migrated. Design: docs/Features/Filename/Filename.md.

Display (imports/lib/fileNameDisplay.js, general cleanFileName + global
{{cleanFilename}}/{{downloadFilename}} helpers): URL-decode, normalize to
generally-used characters (NFKC + confusable-homoglyph folding so typosquatting
"payp<Cyrillic-a>l.exe" shows as "paypal.exe", only in a Latin-majority base so
genuine non-Latin names are preserved), remove invisible/control/bidi chars,
strip HTML/JS/XML/template markup. Removed the earlier invisible-char filter
button, warning triangle, inline descriptions, legend and server invisibleOnly
filter.

Upload (attachments + avatars, all backends): reject exploit-looking filenames
and EICAR virus test files; strip invisible chars; correct the extension to the
real detected type (double-click opens the right app); type-based name when
empty; cap length to a portable max (30, Amiga OS FFS) preserving the extension;
sanitize SVG JS / XML-loop content at the staging location before promoting to
final storage and delete the temp file after; enough-disk-space check with
small-RAM streaming and stop-and-remove-partial on any write error.

Existing files: general storage-agnostic detector streams a small header to
WRITABLE_PATH/files/temp, runs `file`, deletes the temp file; admin-only bounded
batch extension corrector; serve-time on-the-fly exploit sanitizer wired into
httpStreamOutput (sniffs the start, rewrites only dangerous markup, streams the
rest unchanged).

Migrations (moveToStorage/copyFile): sanitize content, finalize the name with
the same general function (detected type, corrected extension, folded
homoglyphs, capped length), save it at the destination, and number
same-name/different-content collisions (document.pdf, document-1.pdf, ...).

Tests: rewrote fileNameDisplay.test.cjs; added uploadFileName, diskSpace,
fileTypeCorrection and fileHardeningGuards unit tests (positive + negative);
updated Playwright spec 41 to the clean-name model. CHANGELOG + design doc.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 626d956ecc Filenames everywhere: reusable +safeFilename (decode + name invisible chars), clean download names.
Made the Files-report filename rendering a GENERAL, reusable component and used it
everywhere filenames are shown:

- New client/components/main/safeFilename.{jade,js,css}: +safeFilename(name=...)
  URL-decodes the name, shows a red warning triangle when it hides invisible
  characters, and replaces each invisible character inline with its red Unicode
  name (all escaped plain text — never markdown/HTML). Plus two global helpers:
  {{filenamePlain name}} (decoded name, invisible chars named — for title="" / JS
  text) and {{downloadFilename name}} (decoded + invisible chars REMOVED).
- Used it in the Admin Panel → Files report and the card attachments (visible
  name via +safeFilename; thumbnail title via filenamePlain; the download="" attr
  via downloadFilename; the attachment viewer title via fileNamePlain()).
- Downloads now save a CLEAN name: the Content-Disposition sanitiser in both
  server/routes/universalFileServer.js and legacyAttachments.js runs the name
  through sanitizeDownloadFileName (URL-decode + remove all invisible/format/bidi
  characters), never empty (falls back to "download").

New fileNamePlain()/sanitizeDownloadFileName() in imports/lib/fileNameDisplay.js;
unit + negative tests in tests/fileNameDisplay.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu e03dec20ca Changelog: Files report names invisible characters inline with a red warning triangle. 2026-07-20 03:11:07 +03:00
Lauri Ojansivu fccc4a7398 Files report: name the invisible characters inline (red) with a red warning triangle, instead of a whole red string.
Instead of colouring the entire filename red, the Files report now:
- shows a red warning triangle (fa-exclamation-triangle) to the left of any name
  that hides invisible characters; and
- replaces each invisible character IN PLACE with its red Unicode description,
  e.g. "evil[U+200B ZERO WIDTH SPACE].png" — the visible parts keep the normal
  text colour.

New fileNameSegments()/describeInvisibleChar() in imports/lib/fileNameDisplay.js
split the (decoded) name into visible runs + one named segment per invisible
character (bracketed "U+XXXX NAME", falling back to the code point for unnamed C1
controls). Everything is still rendered as escaped plain text (Blaze `{{ }}`) —
never markdown/HTML. Unit + negative tests in tests/fileNameDisplay.test.cjs and
the Playwright spec updated to assert the warning + inline names.

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 72d472ab64 Playwright: UI + negative tests for the newest Admin Panel features.
New spec tests/playwright/specs/41-admin-newest-features.e2e.js:
- Files report: seeds attachments (normal, URL-encoded "%D0%93%D1%80" -> "Гр", and
  one hiding a zero-width space) and asserts the encoded name is DECODED, the
  invisible-char name is flagged red (.filename-invisible), the invisible-only
  filter lists only it (and a NEGATIVE toggle-off brings the others back), there is
  NO Search button, and the search field + pagination controls + legend are shown.
- Version (Information) page: shows the live Reactivity mode plus the configured
  METEOR_REACTIVITY_ORDER and DDP_TRANSPORT rows.
- NEGATIVE: the board Table view has no clickable-sortable headers (removed).

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 70a256d616 Changelog: column-header sorting removed + Board Archive pagination/search. 2026-07-20 03:11:07 +03:00
Lauri Ojansivu e205f6b7ac Board Archive -> Boards: server-side pagination + search (stop loading all archived boards).
The archived-boards list published EVERY archived board the user can delete at
once (unbounded, grows on long-lived instances). It now pages server-side
(searchTerm + limit/skip, 30 per page) with a getArchivedBoardsCount method, and
the template gained a search field + prev/next controls in one row (matching the
Admin report tables). Only the current page is ever loaded into the browser.

Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 6d3c550d8d Remove clickable column-header sorting (board Table view + Admin Domains table).
Per request, clickable column-header sorting is removed everywhere it existed:

- Board Table view: the sortable `th.js-table-view-sort` headers become plain
  headers; the sortField/sortDirection state, the sort click handler and the
  sortIndicator arrow are gone. The table now always shows a stable order (card
  title ascending). Search + pagination are unchanged.
- Admin Panel → People → Domains: same removal; the getDomainsWithUserCountsPage
  method no longer takes sortField/sortDirection and returns a fixed domain-
  ascending order (paginateDomains' default). Server-side search + pagination stay.

Dead sort-header CSS removed. Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 3f6273b236 Changelog: Admin pagination tables (theme/layout/Files report/buttons) + Cards & Translation over-fetch fixes. 2026-07-20 03:11:06 +03:00
Lauri Ojansivu 86ebf306d1 i18n: translate the Files-report invisible-character strings to all languages.
Translated filter-invisible-filenames and filename-invisible-legend (the
invisible-only filter button + the red legend) to all 153 languages.

Thanks to translators and xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 19870ad52d Admin Translation page: stop loading the whole Translations collection at once.
The Translation report subscribed with a hardcoded limit of 0, which Mongo/
FerretDB treat as "no limit", so the page loaded EVERY translation string across
all languages into the browser at once. It now passes the infinite-scroll window
(page * 25), so it loads a page at a time and grows as you scroll — the same
bounded pattern the activity feed uses. Guarded in
tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 52acd4b754 Buttons follow the theme: global button base + primary buttons use var(--theme-accent).
WeKan buttons hardcoded their colors, so a Member Settings → Change color theme
override never reached most of them (customTheme.css only themed
`button[type=submit].primary`, not plain `button.primary`, and the global button
base was solid black). Now the global button base and the primary buttons in
client/components/forms/forms.css use `var(--theme-accent, <original>)`, and the
admin action buttons (settingBody.css) and People-panel buttons (peopleBody.css,
incl. ext-box / add-remove-team / unlock-all) do the same. The fallback keeps the
default look identical, but once the user picks a theme accent, the buttons across
the app follow it.

Guarded in tests/buttonThemeColors.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 0a4c2ed412 Admin Files report: decode URL-encoded names, plain-text render, flag + filter invisible characters.
At Admin Panel → Problems → Files report:

- Percent-encoded filenames (e.g. "%D0%93%D1%80" -> "Гр") are URL-decoded for
  display when they decode cleanly; malformed/partial encodings are left as-is.
- Filenames are always shown as PLAIN TEXT — Blaze `{{ }}` HTML-escapes, so a
  name is never rendered as markdown or HTML.
- A filename hiding invisible / control / bidi / zero-width characters (after
  decoding) is shown in RED. A translated legend on the right of the pagination
  controls explains that red = invisible characters.
- A board-view-style filter toggle lists ONLY filenames containing invisible
  characters (server-side, so it works across the whole paginated set); clicking
  it again removes the filter.

New isomorphic helper imports/lib/fileNameDisplay.js (decodeFileNameSafe,
hasInvisibleChars, INVISIBLE_CHARS_SOURCE) is used by both the client (display +
red flag) and the server (the invisibleOnly query on attachmentsList /
getAttachmentsReportCount). Unit + source-guard tests in
tests/fileNameDisplay.test.cjs. English strings added; translations follow.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu dd21cfdc05 Admin pagination tables: theme colors, pagination beside search, no Search button.
Across the Admin Panel → Problems report tables (Files/Rules/Boards/Cards/
Impersonation) and the People/Org/Team/Domain tables:

- Removed the redundant "Search" button — typing in the field + Enter already
  searches (the keydown handler was already there); dropped the now-dead
  js-*-search-button click handlers.
- Put the pagination controls on the RIGHT of the search field, in one row
  (margin-inline-start:auto, so it mirrors correctly in RTL), matching the
  Security/Speed/Tests event table which already had this layout.
- Made the pagination controls follow the current theme: they now use
  var(--theme-accent, #01628c) instead of hardcoded blue/grey/white, so they
  honour Member Settings → Change color (which sets --theme-accent on :root) and
  fall back to WeKan blue when no custom theme is chosen. The event-stream
  pagination links are themed too.

Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu f7c4a916f0 Admin Panel Cards report: load one page fast via an index-backed sort.
The Admin Panel → Problems → Cards report already paginates (limit/skip), but it
sorted by { boardId:1, sort:1 } which has NO index, so every page load full-sorted
all cards in memory — the spinner on a site with 11761 cards. Switched both the
cardsReport publication and the client mirror sort to { boardId:1, createdAt:-1 },
the existing index (server/models/cards.js), so one page is a bounded index scan.

Also added a { stream:1, at:-1 } index on the eventlog collection (and { stream:1 }
on eventlogAcks) so the Security/Speed/Tests report pages stay an index scan as the
log grows, instead of a full scan + in-memory sort.

Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
github-actions[bot] 0114181115 Bump versions for v10.03 2026-07-19 19:47:10 +00:00
Lauri Ojansivu b50b472296 Updated ChangeLog. 2026-07-19 22:45:52 +03:00
Lauri Ojansivu db863ba113 Changelog: note the eventLogCount/eventLogPage check() ordering fix. 2026-07-19 22:40:47 +03:00
Lauri Ojansivu 7da21c46b0 Fix "Did not check() all arguments" spam from eventLogCount/eventLogPage.
The Admin Panel → Problems detail methods eventLogCount/eventLogPage awaited
requireAdmin() BEFORE check()ing their arguments. When requireAdmin throws (a
non-admin call, or before admin status resolves on the client), Meteor's
audit-argument-checks runs and throws "Did not check() all arguments during call
to '<method>'", masking the real error and spamming the server log. Moved the
check() calls to the top of both methods, before any await/throw — matching
acknowledgeEventLog, which was already correct. Guarded in
tests/securityLog.test.cjs (check() must precede requireAdmin in every
argument-taking method).

Thanks to xet7 !
2026-07-19 22:40:28 +03:00
Lauri Ojansivu e36235629c Sandstorm: enable the FerretDB OpLog by default, like the other FerretDB platforms.
Sandstorm defaults to FerretDB but its grain launcher (sandstorm-src/start.js) set
neither --repl-set-name nor MONGO_OPLOG_URL / METEOR_REACTIVITY_ORDER, so it stayed
on poll-and-diff. Now the STEADY-STATE FerretDB is started with --repl-set-name=rs0
and WeKan is pointed at it with METEOR_REACTIVITY_ORDER=oplog,polling (OpLog only
when tailing works, polling fallback). The transient migration-target FerretDB stays
OpLog-free so bulk inserts are not slowed. WEKAN_FERRETDB_OPLOG=false forces polling.

This makes every platform that defaults to FerretDB — Snap, Sandstorm, Docker and
the prebuilt bundle — behave the same. Guarded in tests/ferretdbOplog.test.cjs.

Thanks to uusijani, Nissulya and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 21:18:28 +03:00
Lauri Ojansivu 1ac0a19be9 i18n: translate the two new Version-page keys (Reactivity_order, DDP_transport) to all languages.
The FerretDB OpLog work added two Admin Panel → Version row labels
(Reactivity_order, DDP_transport). Translated both to all 153 languages, keeping
the environment-variable names METEOR_REACTIVITY_ORDER and DDP_TRANSPORT literal
inside the parentheses in every language (including RTL and non-Latin scripts).

Thanks to translators and xet7 !
2026-07-19 21:03:04 +03:00
Lauri Ojansivu c0bd8cbf73 Changelog: note OpLog polling-fallback + Version-page reactivity/DDP env display (#6480, #6481). 2026-07-19 20:52:31 +03:00
Lauri Ojansivu e1aa5a22e8 Admin Panel / Version: show the configured METEOR_REACTIVITY_ORDER and DDP_TRANSPORT env.
Below the live "Reactivity mode" (oplog / changeStreams / polling — what is
actually running) and "MongoDB Oplog enabled" rows, the Version page now also
shows what was REQUESTED via the environment: the reactivity driver priority
order (METEOR_REACTIVITY_ORDER, falling back to DEFAULT_METEOR_REACTIVITY_ORDER)
and the DDP transport (DDP_TRANSPORT). Together they let an admin see at a glance
"I asked for oplog,polling and it is actually running oplog" vs "it fell back to
polling". The values are returned by the existing getStatistics() server method
(reading process.env), not exposed as public settings. Empty env shows '(unset)'.

New i18n keys Reactivity_order and DDP_transport; guard in tests/ferretdbOplog.test.cjs.

Thanks to xet7 !
2026-07-19 20:52:09 +03:00
Lauri Ojansivu fb5a98648a FerretDB: keep polling as the reactivity fallback so a broken OpLog never blocks startup.
Follow-up to enabling the FerretDB OpLog by default (#6480, #6481). Setting
MONGO_OPLOG_URL alone can make Meteor fail to start if OpLog tailing does not
actually work (the same way WeKan on Windows with MongoDB falls back to polling).
Fix: every FerretDB launch path now also sets
METEOR_REACTIVITY_ORDER=oplog,polling — Meteor tries the drivers left-to-right and
uses OpLog ONLY when tailing works, otherwise polling, so a broken/absent OpLog
never blocks startup. Previously only docker-compose set this; the snap
(wekan-control), the bundle (start-wekan.sh/.bat) and the docker entrypoint
(wekan-entrypoint.sh) set MONGO_OPLOG_URL WITHOUT the polling fallback. The
kill-switch path (WEKAN_FERRETDB_OPLOG=false) now selects polling-only.

Admin Panel / Version already reports which driver is actually live: server/
statistics.js derives "Reactivity mode" (oplog / changeStreams / polling) and
"MongoDB Oplog enabled" from Meteor's live _oplogHandle, so it shows `polling`
whenever OpLog did not come up — the direct way to confirm whether FerretDB is
on OpLog or polling.

Tests: tests/ferretdbOplog.test.cjs now asserts the oplog,polling fallback in
every launch path, the polling-only kill-switch path, and that the Version page
detects + displays the live reactivity driver.

Thanks to uusijani, Nissulya and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 20:49:47 +03:00
Lauri Ojansivu 9e842ed326 Changelog: Upcoming update for FerretDB OpLog tailing by default (#6480, #6481). 2026-07-19 20:33:25 +03:00
Lauri Ojansivu 1039b2e328 FerretDB: enable OpLog tailing by default so Meteor stops poll-and-diff (fixes high CPU).
With FerretDB there is no MongoDB oplog, so Meteor observed every live query by
poll-and-diff, pinning FerretDB CPU at 100-390% on busy boards (#6480, #6481).
FerretDB v1 now ships an OpLog (wekan/FerretDB: auto-created capped local.oplog.rs
+ replica-set hello handshake), so every FerretDB launch path now:

- starts ferretdb with --repl-set-name=rs0 (snap ferretdb-control, docker
  entrypoint + docker-compose service, bundle start-wekan.sh/.bat), which makes
  FerretDB auto-create the OpLog and advertise the replica set; and
- points WeKan at it with MONGO_OPLOG_URL=.../local?replicaSet=rs0 (docker-compose
  also switches METEOR_REACTIVITY_ORDER to oplog,polling), so Meteor TAILS the
  OpLog instead of polling.

Enabled by default with a kill-switch: WEKAN_FERRETDB_OPLOG=false forces the old
polling-only behaviour. The calmer polling defaults remain as the fallback for
queries Meteor cannot observe via the OpLog. The transient migration ferretdb
(migration-control) deliberately stays oplog-free so bulk inserts are not slowed.

Also cut the client activity feed page from 500 to 50 rows: 500 was far more than
the sidebar/card feed shows and, under polling, was re-fetched and re-diffed on
every poll; infinite scroll still loads more on demand (the server cursor is
index-bounded either way).

Tests: tests/ferretdbOplog.test.cjs source-guards every launch path (repl-set arg,
MONGO_OPLOG_URL, kill-switch) plus the activity page size, and a negative test that
migration-control stays oplog-free.

Thanks to uusijani, Nissulya and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 20:33:06 +03:00
Lauri Ojansivu 36cfdbf47f Changelog: Upcoming bug fixes for #6480 export listener leak and #6481 FerretDB migration + LDAP email. 2026-07-19 18:36:36 +03:00
Lauri Ojansivu 2f7b49d8de Fix FerretDB migration non-finite doubles, LDAP email mapping, and export listener leak.
#6481 (Nissulya): after MongoDB 3.x -> FerretDB v1 migration, cards with a
non-finite `sort` (±Infinity / NaN, written by old WeKan before #6472) failed to
insert: FerretDB/SQLite rejects non-finite doubles ("invalid value { sort: +Inf }
(infinity values are not allowed)"), aborting the document and leaving boards
stuck on the loading spinner. sanitizeNonFinite() only made the bare mongoexport
tokens PARSE; the parsed value was still a JS Infinity/NaN. Added clampNonFinite()
in the migration tool to reset every non-finite number to a finite 0 before
insert, and a `nonfinite-sort-repair` startup schema-upgrade step that heals the
same corruption in an already-running database (MongoDB or FerretDB), using a
predicate built from finite literals only so it is safe on FerretDB too.

#6481 (Nissulya): LDAP `mail` -> `email` fieldmap returned `undefined` (worked in
6.09). getDataToSyncUserData's email branch read the mapped attribute
case-SENSITIVELY (ldapUser.hasOwnProperty / ldapUser[field]) while the rest of
wekan-ldap uses the case-insensitive getLDAPValue(); a directory returning the
attribute with a different case (exposed by ldapjs -> ldapts, which preserves the
server's case) missed it. The email branch (and the addLdapUser fallback) now
read via getLDAPValue().

#6480 (uusijani): board/CSV export paused on socket backpressure with
`res.once('drain', resolve); res.once('error', reject)`, which leaked one 'error'
listener per backpressured write ("MaxListenersExceededWarning: 11 error
listeners added to [ServerResponse]"). writeWithBackpressure() now removes both
listeners once either settles.

Added tests and negative tests for all three fixes.

Thanks to Nissulya, uusijani and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 18:36:11 +03:00
Lauri Ojansivu 5aaec4e6ac Tests: make loginWithToken robust to the reactive-userId lag on re-login.
Fix the intermittent "Unexpected userId after login" e2e flake (seen in
33-board-domains, which re-logs-in on the same page: admin then non-admin).
Meteor.userId() can lag the Meteor.loginWithToken callback by a tick, so reading
it immediately sometimes returned the PREVIOUS userId. Poll Meteor.userId() until
it settles on the expected id (5s timeout so a genuine mismatch still surfaces).
Pure test-helper change; the direct-Mongo-seed + resume-token login path this
uses does not go through createUser, so it is unaffected by the session security
changes.
2026-07-19 17:23:06 +03:00
Lauri Ojansivu 5ca56c25dd Tests: add functional negative cases for the event-log sanitizer and category catalog. 2026-07-19 14:20:04 +03:00
Lauri Ojansivu 4101c1be2b Tests stream: runtime self-checks (no Playwright required).
Add server/lib/selfChecks.js: at startup (and via an admin-only runSelfChecks
method) run cheap invariants a unit test would assert (database round-trip,
WRITABLE_PATH/files writable) and record any failure to the Tests stream
(Admin Panel -> Problems -> Tests). WeKan does NOT require Playwright: only
failures detectable without a browser are logged, and no runtime code imports
Playwright (asserted by the test). Wired into server/imports.js; design doc
updated.
2026-07-19 14:18:44 +03:00
Lauri Ojansivu 5ee3987d0d Speed: record slow HTTP requests to the Problems->Speed stream.
Add server/lib/speedMiddleware.js: a WebApp middleware that times non-asset HTTP
requests and records those slower than WEKAN_SLOW_REQUEST_MS (default 2000ms; 0
disables) to the speed stream via speedLog. Best-effort, never throws; wired into
server/imports.js. Source-guard test added.
2026-07-19 14:00:25 +03:00
Lauri Ojansivu 6678c92e08 Security log: wire upload / metrics-XFF / export-authz remediation points.
Record more runtime rejections into the eventlog (Admin Panel Problems):
- fileValidation.js: log every upload rejection (dangerous MIME content ->
  xss.mime, mime not on the allow-list -> file.mime, over size limit ->
  file.size).
- metrics.js: log spoofing.xff when a /metrics request carrying an
  X-Forwarded-For header is denied (likely forged-whitelisted-IP attempt).
- export.js: log authz.export at all four export-authorization denial paths.
All calls are best-effort/guarded (server-only require, try/catch) so logging
never breaks the guard. Source-guard tests added.
2026-07-19 13:59:11 +03:00
Lauri Ojansivu 4c046c64a8 i18n: translate the 16 new Admin Panel Problems keys to all languages.
Translate problems, summary, securityReportTitle, speedReportTitle,
testsReportTitle, new-problems, acknowledge, no-new-problems,
problems-summary-help and the event-* table headers into all 153 non-English
languages (en is the source). All files validated as JSON.
2026-07-19 13:52:06 +03:00
Lauri Ojansivu 7839ad15c3 Changelog: Upcoming new features (security/speed/tests logging + Admin Panel Problems page). 2026-07-19 13:43:57 +03:00
Lauri Ojansivu 0085f7fbfa Admin Panel: Problems button + page (Summary/Security/Speed/Tests), replaces banner & Reports button.
- 2nd header bar: add a Problems button (warning icon) to the RIGHT of the Info
  (version) button; it turns red when there are new problems (settingHeader.js
  polls eventLogProblemAreas). Remove the old Reports button; the Admin Panel
  still opens on Settings.
- The former Reports page is now the Problems page: left menu Summary / Security /
  Speed / Tests, then a separator, then the moved Broken Cards/Files/Rules/Boards/
  Cards/Impersonation items.
- Summary (problemsSummary, renamed from the removed top banner): the checkbox
  list + one Acknowledge button — the ONLY place to acknowledge.
- Security/Speed/Tests: read-only, paginated, searchable event tables via new
  admin-only eventLogPage/eventLogCount methods; acknowledgeEventLog now takes a
  stream or array.
- i18n keys + CSS + source-guard tests + design doc updated.
2026-07-19 13:42:47 +03:00
Lauri Ojansivu ff67780b30 Admin banner: checkbox list + one Acknowledge button (acknowledge only in banner).
Change the top problem banner from a per-area button to a checkbox list of
problem areas with a single Acknowledge button that acknowledges every checked
area in one call. acknowledgeEventLog now accepts a stream or an array of
streams. Acknowledging happens ONLY at the top banner; the Reports pages are
read-only (noted in the design doc).
2026-07-19 13:27:09 +03:00
Lauri Ojansivu 4f4140e02b Admin Panel: top banner for new Security/Speed/Tests problems, with Acknowledge.
Like the Announcement banner, the Admin Panel top now shows, for each problem
area with unacknowledged events, its menu path (Admin Panel -> Reports ->
Security/Speed/Tests) and the count of NEW problems. Each area has an Acknowledge
button that marks the newest problems seen, zeroing the count and removing the
info from the top.

- models/eventLog.js: add the eventlogAcks collection and admin-only methods
  eventLogProblemAreas() (count of events newer than each stream ack) and
  acknowledgeEventLog(stream) (upsert ack = now).
- client adminProblemBanner.{jade,js}: poll the counts, render path/count/button,
  acknowledge on click; included in settingHeaderBar (top of every admin page).
- i18n keys (securityReportTitle/speedReportTitle/testsReportTitle/new-problems/
  acknowledge) + banner CSS. Source-guard tests added.
2026-07-19 13:19:50 +03:00
Lauri Ojansivu 1d82ef8eae Security/speed/tests logging: store in the existing WeKan DB (no new files).
Per correction: use only the existing WeKan database via Meteor JavaScript
queries (works on FerretDB and MongoDB); do not create files/DBs under
WRITABLE_PATH; and have FerretDB report problems to WeKan rather than writing
itself.

- models/eventLog.js: one `eventlog` collection (stream: security|speed|tests).
- server/lib/{securityLog,speedLog,testLog}.js: thin wrappers that insert one
  document via EventLog.insertAsync (fire-and-forget, never throw).
- models/lib/securityLogFormat.js slimmed to just sanitizeDetail; remove the
  node:sqlite event store (server/lib/eventStore.js) and all file/path/schema
  helpers.
- Add a Tests stream: log anything that would fail an existing WeKan test.
- Design docs (WeKan.md, FerretDB.md) rewritten: DB-collection storage, grouped
  counts for the summary, Admin Reports read the collection, and FerretDB reports
  problems to WeKan which records them (no FerretDB DB/file writes).
- tests/securityLog.test.cjs updated (sanitizer + catalog + collection wiring +
  no-filesystem guards); verified via Python mirror.
2026-07-19 13:12:59 +03:00
Lauri Ojansivu 9f07aa4506 Security/speed logging: foundation loggers + first upload/avatar/SSRF wiring.
Implements the core of docs/Security/Remediation/WeKan.md:
- models/lib/securityLogFormat.js: pure, unit-testable helpers (dated run-dir
  path, disk-space guard, one-line format, parse, per-category/Bleed/severity
  tally, summary render).
- models/lib/securityCategories.js: general-category <-> hall-of-fame *Bleed
  catalog (RedirectBleed, LiveBleed, SourceBleed, MetricsBleed, InviteBleed, ...).
- server/lib/securityLog.js + speedLog.js: best-effort, never-throwing writers to
  WRITABLE_PATH/files/security|speed/YYYY-MM/DD/HH_MM_SS/ (wekan-log.txt +
  wekan-summary.txt / wekan-speed-*), with a disk-space check before every write
  and a dropped-events counter.
- Wire first remediation/rejection points to emit events: avatar-URL scheme
  rejection (setAvatarUrl), Trello live-import attachment SSRF block, and avatar
  localization SSRF block (covers the requested attachment/avatar rejection +
  sanitization logging).
- tests/securityLog.test.cjs (unit + negative tests), wired into test:unit:node.

Admin Panel Reports (Security/Speed UI), the FerretDB Go logger, and full guard
coverage are the next phase (fully specified in the design docs).
2026-07-19 12:45:03 +03:00
Lauri Ojansivu 4525764bb8 Docs: design for WeKan/FerretDB security + speed auto-remediation, logging and reports. 2026-07-19 12:39:05 +03:00
Lauri Ojansivu fd4874a9f7 Changelog: Upcoming CRITICAL section for the 7 meifukun security fixes. 2026-07-19 12:25:13 +03:00
Lauri Ojansivu 1669a1af19 Fix 7 security issues reported by meifukun.
- Avatar localization redirect SSRF: fetch avatars via fetchSafe (DNS-pinned,
  redirects rejected) instead of native redirect:follow, and validate
  profile.avatarUrl scheme (http/https/data:image/local) at write time.
- Trello import source.url stored XSS: only render/store an http(s) activity
  source URL, so a javascript: board url can no longer execute on click.
- Trello LIVE import readable SSRF: gate every attachment/background/avatar
  download with validateAttachmentUrl, matching the offline import path.
- CAS login global user-data race: store validated CAS user data per credential
  token instead of a module-global _userData (fixes account-takeover race).
- Metrics X-Forwarded-For whitelist bypass: only trust XFF behind
  METRICS_TRUST_PROXY (parsed spoof-resistantly from the right); else use the
  socket peer address.
- Stale impersonation export bypass: remove the "|| impersonateDone" export
  authorization bypass; require real board visibility (canExport) only.
- Invitation code brute force: generate invitation codes with a 128-bit CSPRNG
  (crypto.randomBytes) instead of a 6-digit Math.random value, and rate-limit
  createUser (DDPRateLimiter).

The OIDC shared serviceData race from the same report was already fixed (#4897,
the per-login objects are callout-local). Source-guard regression test added
(tests/securityMeifukun.test.cjs) and wired into test:unit:node.

Thanks to meifukun and xet7 !
2026-07-19 12:22:56 +03:00
Lauri Ojansivu 6a9d4c37b8 Updated ChangeLog. 2026-07-19 11:51:45 +03:00
Lauri Ojansivu 1e0ffc5c36 Updated SECURITY.md .
Thanks to xet7 !
2026-07-19 11:51:45 +03:00
github-actions[bot] bbcbee41c9 Bump versions for v10.02 2026-07-19 01:16:57 +00:00
Lauri Ojansivu 8d30efa117 Updated ChangeLog. 2026-07-19 04:15:37 +03:00
Lauri Ojansivu 6f70544c60 Changelog: add merged dependabot updates to Upcoming. 2026-07-19 04:14:14 +03:00
Lauri Ojansivu acc86fa86a Merge pull request #6482 from wekan/dependabot/github_actions/actions/setup-node-7
Bump actions/setup-node from 6 to 7
2026-07-19 04:11:04 +03:00
Lauri Ojansivu 96c59b2d01 Merge pull request #6485 from wekan/dependabot/npm_and_yarn/aws-sdk/client-s3-3.1090.0
Bump @aws-sdk/client-s3 from 3.1085.0 to 3.1090.0
2026-07-19 04:08:43 +03:00
Lauri Ojansivu 1a88240137 Merge pull request #6486 from wekan/dependabot/npm_and_yarn/dompurify-3.4.12
Bump dompurify from 3.4.11 to 3.4.12
2026-07-19 04:07:29 +03:00
Lauri Ojansivu d8bda40806 Merge pull request #6487 from wekan/dependabot/npm_and_yarn/filesize-11.0.22
Bump filesize from 11.0.17 to 11.0.22
2026-07-19 04:06:55 +03:00
Lauri Ojansivu 2c2e8a6fee Changelog: add Upcoming section (auto-update docs set + release automation). 2026-07-18 23:43:43 +03:00
Lauri Ojansivu cf55da122a Docs: move Autoupdate/Snap.md to Autoupdate/Forks/Snap-Core.md and repoint links. 2026-07-18 23:42:47 +03:00
dependabot[bot] ccb7a8e91c Bump filesize from 11.0.17 to 11.0.22
Bumps [filesize](https://github.com/avoidwork/filesize.js) from 11.0.17 to 11.0.22.
- [Changelog](https://github.com/avoidwork/filesize.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/avoidwork/filesize.js/commits/11.0.22)

---
updated-dependencies:
- dependency-name: filesize
  dependency-version: 11.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:33:31 +00:00
dependabot[bot] 963f72ac88 Bump dompurify from 3.4.11 to 3.4.12
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:33:25 +00:00
dependabot[bot] 144bed0eda Bump @aws-sdk/client-s3 from 3.1085.0 to 3.1090.0
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1085.0 to 3.1090.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1090.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1090.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:33:14 +00:00
dependabot[bot] 1f11ad2602 Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:32:23 +00:00
Lauri Ojansivu 2054ea2b54 release-all.yml: add guarded snap-variants / ucs / nextcloud publish jobs.
Wire in three new publish jobs (dormant until their secrets are set — each
guards on its secrets and skips with a ::notice:: so ordinary releases are
unaffected):
- snap-variants: build+publish wekan-ondra and wekan-gantt-gpl snaps.
- ucs: bump + push the wekan/univention app and upload to the App Provider.
- nextcloud: build the ExApp image and publish to the Nextcloud App Store.
Document the new secrets in the header. Add a "Remaining steps" section to the
top of Snap-Ondra-Gantt.md, UCS.md and Nextcloud.md (keys to add + how).
2026-07-18 23:29:56 +03:00
Lauri Ojansivu 8ae0ee5446 Docs: Nextcloud.md — WeKan ExApp build/publish + AI/Deck/SSO integrations. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu c1ffed59a7 Docs: move the per-OS update comparison docs into Autoupdate/OS/ subdir.
The 29 per-OS manual-vs-automatic update comparison docs move into OS/;
the WeKan-specific docs (README, Snap, Snap-Ondra-Gantt, UCS) stay in the
parent. All cross-links repointed and verified.
2026-07-18 23:22:19 +03:00
Lauri Ojansivu 58c0e6d1f8 Docs: UCS.md — releasing Docker WeKan to UCS App Center with MongoDB 3.x -> FerretDB migration. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu cf1823b510 Docs: per-OS install/update comparison set in Design/Autoupdate/.
Add manual-vs-automatic (Snap-like) update comparison docs for all operating
systems with any Internet update mechanism: Linux, BSD, Haiku, Amiga, RISC OS,
Solaris/illumos, AIX, HP-UX, OpenVMS, z/OS, ArcaOS, ReactOS, FreeDOS, Plan 9,
Redox, SerenityOS, Android, iOS, ChromeOS, HarmonyOS, Ubuntu Touch, Sailfish,
postmarketOS, KaiOS, Tizen, webOS and TV/streaming OSes; plus a README index.
Add Snap-Ondra-Gantt.md (keys/secrets + release-all.yml changes to also release
the wekan-ondra and wekan-gantt-gpl snaps). Rename the two earlier dev docs to
Snap.md / Windows.md and repoint all links.
2026-07-18 23:22:19 +03:00
Lauri Ojansivu 2a878d55e7 Docs: macOS install & update platforms (manual vs automatic, like Snap). 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 48a60a1141 Docs: move the auto-update dev docs into Design/Autoupdate/. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 3bfc6cce56 Docs: Windows install & update platforms (manual vs automatic, like Snap). 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 8cf7dd68cc Docs: move Mac.md into Propietary/Mac/ and update all links to the new path. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 742fc9221a Docs: why ppc64el/s390x snap builds fail on core24 (multiarch action caps at core22). 2026-07-18 23:22:19 +03:00
github-actions[bot] add47dc065 Bump versions for v10.01 2026-07-18 19:01:13 +00:00
Lauri Ojansivu 5af16df201 Updated ChangeLog. 2026-07-18 22:00:12 +03:00
Lauri Ojansivu adaecfe8c2 Changelog: note the zh-alias/header-cog test fixes in Upcoming. 2026-07-18 21:24:10 +03:00
Lauri Ojansivu 08cfe2d788 Tests: fix zh-alias and header-cog regressions found by the full test run.
- i18n: registering generic Chinese (zh) as its own entry made bare "zh"
  resolve to itself, so the "zh -> zh-Hans" alias is now dead code — remove it
  from LANGUAGE_ALIASES (keep wuu -> wuu-Hans) and update the two mocha tests
  (resolveTag/setLanguage) to expect "zh" instead of "zh-Hans".
- e2e (15-board-actions): the Board Settings cog was removed from the header
  (it is in the right sidebar now), so the "one click from the header cog" test
  failed in every browser. Rewrite it to open the sidebar and open the board
  menu (which still has Archived Items) from there.
2026-07-18 21:18:10 +03:00
Lauri Ojansivu ef6b2794b8 Changelog: add Upcoming section with the changes made after v10.00.
The changes committed since the v10.00 release bump had no CHANGELOG Upcoming
section. Add one above v10.00 covering all of them: the "Submit editors with
Enter" Member Setting, the right-sidebar drag-to-resize width feature, the #6481
migration NaN/Infinity fix, the #6465 Add List inline-composer options restore,
the right-sidebar width/inputs fix, and the missing copy-selection key — each
with commit links.
2026-07-18 20:37:00 +03:00
Lauri Ojansivu 1284afc588 i18n: translate drag-to-resize-sidebar tooltip to all languages.
Add the sidebar resize handle tooltip (drag-to-resize-sidebar, "Drag to resize
sidebar") to every language file, translated into each language (English-variant
locales carry the English text). All 154 files JSON-validated; value-only
additions.
2026-07-18 19:49:01 +03:00
Lauri Ojansivu 354b7804b5 Right sidebar: drag the edge to resize its width (desktop), saved per user.
Add a resize handle on the sidebar edge, like resizing a spreadsheet column
(mirrors the list resize in client/components/lists/list.js). Drag it to change
the sidebar width; the width is saved to profile.sidebarWidth for logged-in
users and to browser localStorage (wekan-sidebar-width) for anonymous users on
a public board, then re-applied on load.

- The handle uses the LOGICAL inset-inline-start, so in LTR it is the left edge
  of the right-docked sidebar and in RTL it is the RIGHT edge of the left-docked
  sidebar; the drag direction inverts for RTL so dragging always widens/narrows
  the visible panel correctly.
- Desktop only: on phones (<=800px) the handle is hidden and the sidebar stays
  full width (the 100vw rule is now !important so a desktop inline width can
  never leak into mobile).
- Width is clamped (min 280px, capped near the viewport) and re-applied on
  window resize so crossing the mobile breakpoint clears/restores it.

New profile.sidebarWidth schema field + getSidebarWidth/setSidebarWidth helpers,
setSidebarWidth Meteor method, i18n (en, fi), and source-guard test
tests/sidebarResize.test.cjs.

Thanks to xet7 !
2026-07-18 19:42:19 +03:00
Lauri Ojansivu 72042b7d79 i18n: add missing copy-selection key (Multi-Selection sidebar) in all languages.
The Multi-Selection right-sidebar "Copy selection" button used {{_ (quote)copy-selection(quote)}}
but the key was missing from every language file — including the en source —
so i18next had no value to fall back to and rendered the raw key. Add
copy-selection ("Copy selection") to en and translate it to all languages,
mirroring each language(apos)s existing "move-selection" sibling with the verb changed
to copy. All 154 files JSON-validated; value-only additions.

Thanks to xet7 !
2026-07-18 19:29:40 +03:00
Lauri Ojansivu b8a21e4ce3 Right sidebar: bound the width and make its input fields fill the width.
- .board-sidebar used width: 30vw, which scaled with the viewport: on very wide
  screens it grew to ~double the intended width (it looked right only at mid
  widths). Use width: clamp(320px, 30vw, 560px) so it keeps the mid-width look
  but is capped (and floored) instead of scaling without bound. Phones (<800px)
  still get the full-width 100vw rule.
- The sidebar filter text inputs (Filter List by Title, Filter by card title,
  advanced filter) had no width, so they rendered at the browser default narrow
  width with a big empty gap on the right. Sidebar text/search inputs now
  width: 100% + box-sizing: border-box, so they fill the padded content area
  with equal empty space on both sides (the .sidebar-content 12px padding).

Source-guard test tests/sidebarWidth.test.cjs.

Thanks to xet7 !
2026-07-18 19:18:17 +03:00
Lauri Ojansivu b71461d90c Add List: restore the previous options in the inline composer (#6465).
The #6465 per-list-header inline Add List composer (addListInline) had been
reduced to just a title input, dropping the options the old addListForm had.
Restore them:
- the "Add after which list" position <select>, listing the swimlane lists and
  pre-selecting the list whose header button opened the composer; submit reads
  it for afterListId and derives nextListId from the following option so the new
  list is inserted between them (falls back to the header target for the empty
  board case).
- the "or template" link (js-list-template) to create a list from a template.
Helpers swimlaneLists()/isDefaultAfterList() added; createListAfter already
accepts nextListId. Source-guard test tests/addListComposerOptions.test.cjs.

Thanks to csonkaoszimt and xet7 !

Fixes #6465.
2026-07-18 18:55:19 +03:00
Lauri Ojansivu b8bb004650 i18n: translate submit-on-enter and its description to all languages.
Add the two new Member Settings keys (submit-on-enter, submit-on-enter-
description) to every language file in imports/i18n/data/, translated into each
language and inserted after show-desktop-drag-handles. English-variant locales
(en-GB, en_AU, ...) carry the English text; each real language matches its own
terminology, and the keyboard-key tokens Enter, Shift+Enter and Ctrl/Cmd+Enter
are kept verbatim. All 154 files JSON-validated; value-only additions.
2026-07-18 18:25:59 +03:00
Lauri Ojansivu bb6b0d5477 Member Settings: optional "Submit editors with Enter" (per-user, off by default).
#4236 made every multi-line editor submit only on Ctrl/Cmd+Enter (plain Enter
= newline) so titles could be multi-line, but that removed the fast
Enter-to-save title workflow some users rely on (v10 email feedback). Add a
per-user preference in the Member Settings (Change Settings) popup, saved to
profile.submitOnEnter and OFF by default: when on, plain Enter submits the card
title, description and other inlined forms, and Shift+Enter inserts a newline;
when off, behaviour is unchanged (Ctrl/Cmd+Enter saves).

The rule stays in the single pure isSubmitKey(event, {submitOnEnter}); the card
title, the card-description override and the shared client/lib/inlinedform.js
all route through it and read the preference from the current user. New toggle
+ helper + toggleSubmitOnEnter method + schema field, i18n (en, fi), and
editorSubmitKey.test.cjs extended with positive + negative opt-in cases.

Thanks to xet7 !

Related to #4236.
2026-07-18 18:14:33 +03:00
Lauri Ojansivu e0991d6602 Migration: keep cards/activities whose export has NaN/Infinity doubles.
mongo 3.x mongoexport emits non-finite doubles as the bare tokens NaN,
Infinity, +Infinity and -Infinity, which are not valid JSON, so EJSON.parse
threw ("Unexpected token N/+") and migrate-mongo3-to-ferretdb.mjs dropped the
WHOLE document — leaving boards/cards missing after an otherwise "successful"
migration. sanitizeNonFinite() now rewrites those bare tokens, only outside
string literals, to the canonical EJSON v2 double form {"$numberDouble":"..."}
that EJSON.parse accepts, so the document migrates instead of being lost.
Strings that merely contain "NaN"/"Infinity" and ordinary negative numbers are
left untouched. Test tests/migrateNonFinite.test.cjs (with negative cases).

Thanks to Nissulya and xet7 !

Fixes #6481.
2026-07-18 16:42:11 +03:00
Lauri Ojansivu 077e2196f5 Updated ChangeLog. 2026-07-18 16:34:50 +03:00
Lauri Ojansivu e010c09e87 Changelog: rewrite v10.00 from what commits actually implemented.
Rewrite the v10.00 section to match the final implementation and group ALL 101
commits in the release under their own headings (each with commit links):

- Corrected "done differently" cases: Change Color is visible grouped swatches
  (not the initial two-level dropdowns); Font is font-name + size buttons with
  immediate apply (not dropdowns); the board-header "icons only" change was
  reverted so button texts stay.
- Fixed the Home board entry: it is set via the All Boards Multi-Selection
  "Selected:" home action and the after-login redirect runs on all deployments,
  so it is available, NOT disabled and NOT Sandstorm-only (an earlier draft
  wrongly said the UI was disabled).
- Every commit since v9.99 is now listed once, grouped by feature/fix area.
2026-07-18 08:46:01 +03:00
github-actions[bot] 356b1152bb Bump versions for v10.00 2026-07-18 05:11:26 +00:00
Lauri Ojansivu 66a2f8ddc8 Updated ChangeLog. 2026-07-18 08:09:43 +03:00
Lauri Ojansivu ef81c25232 ci: disable Playwright E2E Tests workflow.
Rename .github/workflows/playwright.yml -> playwright.yml.disabled so GitHub
Actions no longer runs it (only .yml/.yaml files in .github/workflows are
executed). Reversible by renaming back. NOTE: this workflow also contained the
Meteor unit-tests job and a Puppeteer regression job, so those stop running too.
2026-07-18 08:01:52 +03:00
Lauri Ojansivu 2be9253839 releases: fix push-all-translations.sh cd depth for its translations/ location.
The script lives in releases/translations/, so the repo root is two levels up
(../..), not one. With a single .. it landed in releases/ and looked for ../tx
at wekan/tx instead of the sibling ../tx, aborting with "tx not found". Match
the cd "$(dirname "$0")/../.." pattern used by the other scripts here.
2026-07-18 07:54:28 +03:00
Lauri Ojansivu 2cf878215d Changelog: add Part 12 (Waray wa-RR fix) to the 154-languages entry. 2026-07-18 07:43:33 +03:00
Lauri Ojansivu c00f24d668 i18n: fix wa-RR — translate as Waray (war), not Walloon.
The .tx config maps Transifex "war" -> local wa-RR.i18n.json, i.e. wa-RR is
Waray/Winaray (a Philippine language), NOT a regional variant of Walloon (wa).
An earlier seed wrongly copied Walloon content into it. Retranslate the file
fresh from English into Waray (2191 strings); placeholders/brand names
preserved, key set unchanged, JSON validated.
2026-07-18 07:43:10 +03:00
Lauri Ojansivu 95552881fc Moved translation script to correct directory. Thanks to xet7. 2026-07-18 07:34:17 +03:00
Lauri Ojansivu 5b3394af2f Changelog: WeKan translated to 154 languages, with commit links. 2026-07-18 07:18:22 +03:00
Lauri Ojansivu d55c5e2c3c i18n: register wuu-Hans, fix Wolof/Malay names, add wuu browser alias.
- Register Wu Chinese (wuu-Hans): it had a data file but no languages.js
  entry, so it was neither browser-detectable nor shown in Change Language.
- Add wuu -> wuu-Hans to LANGUAGE_ALIASES so a bare "wuu" browser locale
  resolves (mirrors zh -> zh-Hans).
- Fix display names that were in the wrong script for their (Latin) content:
  Wolof "ولوفل" -> "Wolof"; Malay "بهاس ملايو" -> "Bahasa Melayu" (+ Malaysia).
km_KH/ru_RU are symlinks to km-KH/ru-RU, already covered. Every data file is
now registered, so all are browser-detectable and visible in the popup.
2026-07-18 07:16:00 +03:00
Lauri Ojansivu 01d55828d2 WeKan has been translated to 154 languages.
Thanks to xet7 !
2026-07-18 07:11:49 +03:00
Lauri Ojansivu 219011e5eb i18n: translate untranslated strings (wave 7, final: 14 languages + 5 variants).
Fresh translations: vi, uz, uz-AR (Arabic-script Uzbek), ve (Venda), ve-CC
(Venetian), ve-PP (Vepsian), vo (Volapuk), wa (Walloon), wo (Wolof), xh
(Xhosa), yi (Yiddish), yo (Yoruba), zgh (Tamazight/Tifinagh), zu (Zulu).
Seeded variants uz-UZ/uz-LA, vi-VN, wa-RR, zu-ZA from bases. Completes the
full 153-language set. Placeholders/brand names preserved; JSON validated.
2026-07-18 06:58:27 +03:00
Lauri Ojansivu 91ccef062b i18n: translate untranslated strings (wave 6: 15 languages + 2 variants).
Fresh translations: nb, oc, or_IN, pa, ro, sk, sl, sr, sw, ta, te-IN, th,
tk_TM, tlh (Klingon), ug (Uyghur). Seeded variants ro-RO, sl_SI from bases.
Terminology matches each file glossary + standard Kanban terms; brand names,
acronyms, env vars, placeholders and markup preserved verbatim. JSON validated.
2026-07-18 06:28:58 +03:00
Lauri Ojansivu 97feb986a8 i18n: translate untranslated strings (wave 5: 15 languages + 5 variants).
Fresh translations: gl, gu-IN, hi, hr, hu, hy, id, ig, ka, km, lt, lv, mk, mn,
ms. Seeded variants from those bases: gl-ES; hi-IN; km-KH/km_KH; ms-MY.
Terminology matches each file glossary + standard Kanban terms; brand names,
acronyms, env vars, placeholders and markup preserved verbatim. JSON validated.
2026-07-18 05:43:34 +03:00
Lauri Ojansivu a184a3a717 i18n: translate untranslated strings (wave 4: 15 languages + 9 variants).
Fresh translations: ace, af, ast-ES, az, be, bg, br, ca, cy, el, eo, et-EE,
eu, fa, fy. Seeded regional variants from those bases: af_ZA; az-AZ/az-LA;
ca_ES/ca@valencia; cy-GB; el-GR; fa-IR; fy-NL. Terminology matches each file
glossary + standard Kanban terms; brand names, acronyms, env vars, placeholders
and markup preserved verbatim. JSON validated. ~18800 strings.
2026-07-18 04:56:25 +03:00
Lauri Ojansivu 75ec34e837 i18n: register generic Chinese (zh) so it is selectable and browser-detectable.
zh.i18n.json existed but had no entry in the languages registry, so a browser
reporting bare "zh" could not auto-detect it and it was missing from the Change
Language list. Add it (code zh, name 中文). The other two unregistered data
files (km_KH, ru_RU) are underscore duplicates already covered by the existing
km-KH / ru-RU entries, whose tag is the underscore form.
2026-07-18 04:37:28 +03:00
Lauri Ojansivu c795d13450 i18n: seed close-relative lects as baselines (cmn, wuu, yue, ja-Hira, vls, ary).
Seed Mandarin/Wu/Cantonese (cmn, wuu-Hans, yue_CN) from zh-CN, Japanese-Hiragana
locale (ja-HI) from ja, West Flemish (vl-SS) from nl, and Moroccan Arabic (ary)
from ar as machine-quality baselines sharing the written form, for the community
to refine. Placeholders preserved; JSON validated. 4074 strings.
2026-07-18 04:17:19 +03:00
Lauri Ojansivu ea4a10c212 i18n: seed 32 regional variants from their translated base languages.
Fill untranslated strings in same-language, same-script regional variants by
copying the freshly-translated base: de -> de-AT/de-CH/de_DE; es -> es-AR/-CL/
-CO/-LA/-MX/-PE/-PY/es_CO; fr -> fr-CH/fr-FR; ru -> ru-RU/ru_RU/ru-UA; pl-PL;
cs-CZ; ko-KR; uk-UA; pt -> pt-PT/pt_PT; zh-CN -> zh-Hans/zh/zh_SG/zh-GB;
zh-TW -> zh-Hant/zh-HK; he-IL; nl-NL; ar -> ar-DZ/ar-EG. Keeps variants
consistent with their base and preserves placeholders. JSON validated.
24632 strings.
2026-07-18 04:16:11 +03:00
Lauri Ojansivu 7691829cdb i18n: translate untranslated strings (wave 2: 15 major languages).
Fill previously-untranslated UI strings in de, fr, es, it, nl, ru, pl, uk, cs,
ja, ko, zh-CN, tr, ar, pt. Each language matches its existing file terminology
as a glossary plus standard Kanban terms; product/brand names, acronyms, env
vars, console commands, placeholders (__var__, %s, {{..}}) and markup preserved
verbatim. Value-only changes; every file JSON-validated.

de 524, fr 617, es 917, it 938, nl 458, ru 974, pl 1037, uk 895, cs 1108,
ja 926, ko 850, zh-CN 929, tr 1059, ar 1145, pt 500 strings.
2026-07-18 04:12:59 +03:00
Lauri Ojansivu 63bda22faa i18n: translate untranslated strings (wave 1: zh-TW, pt-BR, da, he, sv).
Fill previously-untranslated UI strings in the five near-complete languages,
matching each file existing terminology as a glossary and standard Kanban
terms (board, list, card, swimlane, label, checklist, theme, font, ...).
Product/brand names, acronyms, env vars, placeholders (__var__, %s, {{..}})
and markup preserved verbatim. Value-only changes; JSON validated.

zh-TW 29, pt-BR 28, da 159, he 262, sv 382 strings.
2026-07-18 03:59:03 +03:00
Lauri Ojansivu a9f2e9f50c releases: add translations-push-all-translations.sh (force-push all languages).
Pushes the English source, then force-pushes every language translation to
Transifex in one run (../tx push -s; ../tx push -t -f). -f is required or the
Go tx CLI skips languages whose local file is not newer than the server copy.
Warns that it overwrites newer community translations, so pull first if needed.
2026-07-18 03:49:35 +03:00
Lauri Ojansivu 861ded439e Updates 2026-07-18 03:36:21 +03:00
Lauri Ojansivu 687a4af861 i18n: translate new admin/import-export strings to Finnish.
Translate the 21 previously-English strings in fi.i18n.json: the disable-*
and anonymize-* import/export/activities/notifications/watch admin feature
labels and descriptions, plus impersonation report title, impersonated user
and reason. Terminology follows existing Finnish strings (seuraaminen,
toimet, profiilikuva, tuonti/vienti, esiintyä käyttäjänä).
2026-07-18 03:32:36 +03:00
Lauri Ojansivu d67d27f0d8 All Boards: "Selected:" star/home actions + Home board badge (#2220).
Below the "Multi-Selection is on" button a "Selected:" row now offers two
actions on the multi-selected boards:
- star icon: stars every selected board (sets them as Starred).
- home icon: sets the first selected board as the Home board (opened after
  login) via the existing toggleDefaultBoard method + router redirect, which
  brings the previously hidden Home-board feature back into use.

The Home board also always shows FIRST in the Starred list (even when it is
not itself starred), badged with a home icon centred at the top of its tile.
2026-07-18 03:19:42 +03:00
Lauri Ojansivu 3736f8431a All Boards: half-width search, hint below Multi-Selection, title beside icon.
- Search boards box is now ~half width (300px); the input flexes to fill it.
- "Click checkboxes to select boards" hint moved out of the path title and
  stacked directly below the "Multi-Selection is on" button (own column).
- Folder icon and the menu title (e.g. "Remaining") are grouped so the title
  always sits to the RIGHT of the icon instead of wrapping below it.
2026-07-18 03:12:18 +03:00
Lauri Ojansivu 01088ea9d4 Notifications menu: make the dropdown item icons black.
The Filter by Unread / View All / Delete All Notifications item icons
(envelope, copy, trash, check) were faint; force the .menu-icon and .check-icon
(and their i.fa) to black.
2026-07-18 02:59:58 +03:00
Lauri Ojansivu 5e014d0967 Notifications drawer: fix X position — cancel the global absolute .close rule.
The X dropped below the header because a global `.close { position: absolute;
top: 5px }` rule (boardBody.css) applied once my rule stopped setting position.
Set position: static on the drawer .close so it stays a flex item and aligns
with the Notifications title at the right edge of the header row.
2026-07-18 02:55:25 +03:00
Lauri Ojansivu c87b4cb510 Notifications drawer: full width + robust flex header (black hamburger + X).
- The drawer and its header are now full width (100vw) instead of 400px.
- Rebuild the header as a flex row (hamburger left, title centre via flex:1, X
  right) instead of fragile absolute positioning, so the X is aligned with the
  Notifications title at the right edge (was sitting too low).
- Force the hamburger and X to black with !important (they were inheriting the
  themed header white link colour), overriding the surrounding themed bar.
2026-07-18 02:51:40 +03:00
Lauri Ojansivu 8dd6bd9db5 Notifications drawer header: black X + hamburger, X near the top-right edge.
- The X close button is now black, moved up (top: 8px, aligned with the
  "Notifications" title instead of vertically centred lower) and to the far
  right (inset-inline-end: 4px).
- The left hamburger menu toggle is black (was #333).
2026-07-18 02:44:51 +03:00
Lauri Ojansivu 8cb4050cf2 Member menu: remove the Notifications entry (duplicates the header bell icon).
The member-menu "Notifications" item and its js-notifications-drawer-toggle
handler toggled the same notifications drawer as the header bell icon
(notifications.js), so it was a duplicate. Remove the menu entry and its
now-unused handler; the header bell is unchanged.
2026-07-18 02:41:47 +03:00
Lauri Ojansivu a4cc0c2182 Search All Boards: label the clear button "Clear" using existing trello-clear-job key.
The js-new-search button clears the search (empties the input + query), so it
is a Clear button. Replace its broom icon with the text of the existing
trello-clear-job translation (en "Clear" / fi "Tyhjennä") and use it for the
aria-label too.
2026-07-18 02:39:09 +03:00
Lauri Ojansivu 98a03bf045 Search All Boards: new-search button follows theme override colors.
The global-search button hardcoded belize #216694 (inline !important styles +
.board-color-belize CSS), ignoring the theme. Make it a .primary type="submit"
button (safe: the search form has onsubmit="return false") so it uses the
theme override colour via the existing button[type=submit].primary rules
(default blue, or the board/global theme), and remove the hardcoded belize
styles.
2026-07-18 02:36:30 +03:00
Lauri Ojansivu 4dc09c62b9 Archived boards: Restore/Delete buttons follow the theme override colors.
The theme override colours .primary buttons via `button[type=submit].primary`
(board-color-* and custom-theme rules). A <button> without an explicit type
attribute does not match [type=submit] in CSS (though it already behaves as
submit), so the archive Restore/Delete buttons kept the default blue. Add
type="submit" (no behaviour change) so they match the theme rules and use the
global theme override colour (named or custom).
2026-07-18 02:32:55 +03:00
Lauri Ojansivu 1c8ba2afc3 Archived boards modal: start below the header bar so its top is visible.
The header bars have a higher z-index than #modal, so the archive panel top
(and the close X) was hidden under the top header bar. Offset the archive
modal content 96px from the top (clears both the quick-access and board header
bars) and extend to the window bottom via min-height calc(100vh - 106px).
2026-07-18 02:29:48 +03:00
Lauri Ojansivu b027594ca8 Archived boards modal: full width + extends to the bottom of the window.
The archive modal used the default .modal-content (500px, margin: 5vh auto),
so archived boards were cramped and the panel stopped short of the window
bottom. Target the archive modal (the only one containing .archived-lists) and
make it 96vw wide with no bottom margin + min-height calc(100vh - 2vh) so it
reaches the bottom of the browser window; each archived board is a full-width
row.
2026-07-18 02:25:30 +03:00
Lauri Ojansivu 08a2e5525e Change Avatar popup: Upload button uses theme colors (blue bg, white text).
Add the .primary class to both "Upload an avatar" buttons so they use the
theme primary-button style (default #005377 blue background + white text,
overridden by the board/global theme color) instead of the plain grey/black
default button.
2026-07-18 02:21:16 +03:00
Lauri Ojansivu 3d190ca022 Font popup: style the "Unset" colour reset links as buttons.
Give .js-reset-text-color / .js-reset-bg-color a button appearance (border,
padding, background, hover) with a dimmed disabled state, matching the font/
size buttons.
2026-07-18 02:19:04 +03:00
Lauri Ojansivu aa15e72682 Member Settings / Font: show the preview pangram below the font-name buttons.
Move the "The quick brown fox ... 0123456789" preview from the bottom of the
popup to directly below the font-name buttons.
2026-07-18 02:18:25 +03:00
Lauri Ojansivu bd8e417be1 Member Settings / Font: font-name + size buttons, immediate apply (no dropdowns).
Redesign the Font popup:
- Font: one button per installed font, each shown IN ITS OWN font (via inline
  style), plus a Default button. Clicking a font applies it immediately.
- Size: a row of buttons ordered by percent — smaller on the left, 100%
  (Default) in the middle, larger on the right. Clicking a size applies it
  immediately.
- Colors: the text/background color wheels now apply immediately on change /
  reset. Removed the Save button (everything applies on click).

New .ui-font-btn / .ui-font-size-btn styling (with .active) in labels.css.
Tests updated in tests/uiFonts.test.cjs.
2026-07-18 02:16:53 +03:00
Lauri Ojansivu fc0649a756 Change Language popup: show languages in multiple columns when there is width.
Widen the language popup responsively (width: min(90vw, 1100px), max-width:
90vw) and lay the list out as an auto-filling grid (repeat(auto-fill,
minmax(170px, 1fr))) so as many languages as possible are visible at once on
wide windows, collapsing to a single column when narrow.

Guarded by tests/changeLanguageColumns.test.cjs.
2026-07-18 02:05:00 +03:00
Lauri Ojansivu 969f96d742 Member Settings / Font popup: add a title (changeFontPopup-title).
Give the Font popup a header like the Change Color popup: en "Font",
fi "Fontti".
2026-07-18 02:01:29 +03:00
Lauri Ojansivu 0f87c3bf8b Change Color popup: remove the "Preview" text from the custom-color preview swatch.
The preview swatch (shown for flat/clear custom colors) still displays the
chosen colour, but no longer shows the "Preview" text label inside it.
2026-07-18 01:59:29 +03:00
Lauri Ojansivu 834698bb5e Revert board-header icons-only; update CHANGELOG Upcoming with newest changes.
- Revert the unconditional hiding of board-header button text labels (Private,
  Muted, Sort, Filter, ...): they show again at normal widths. The pre-existing
  mobile-only (max-width:800px, #6419) icons-only rule is untouched.
- CHANGELOG Upcoming: add consolidated entries for the recent All Boards /
  board-header layout polish, the Change Color swatches / immediate-apply /
  applies-everywhere / popup title, the #4759 user-insert validator fix, and the
  rebuild-wekan.sh CURRENT-IP fix; mark the #2220 home-board toggle as currently
  disabled.
2026-07-18 01:56:47 +03:00
Lauri Ojansivu cd7f9f7bc9 All Boards: move "+ Add Template Container" text 2px down (separate from Add Board).
"+ Add Board" and "+ Add Template Container" share the .js-add-board .label
element, so add an .is-template-container class to the templates-menu variant
and give it padding 20/8/22/8 — 2px lower than "+ Add Board" (18/8/24/8) —
while keeping the same 42px total vertical padding so the tile height is
unchanged.
2026-07-18 01:51:37 +03:00
Lauri Ojansivu 519c04b8ef All Boards: move "+ Add Board" text 2px further up (padding 18/8/24/8). 2026-07-18 01:50:07 +03:00
Lauri Ojansivu 3f5173b960 All Boards: move "+ Add Board" text 4px up (keep tile height).
Shift the .js-add-board .label vertical padding (top 24->20, bottom 18->22)
so the "+ Add Board" / "+ Add Template Container" text sits 4px higher, while
keeping the same 42px total vertical padding so the grey tile stays the same
height as the board icons. Test now checks total vertical padding equality
(height) rather than an exact padding string.
2026-07-18 01:49:40 +03:00
Lauri Ojansivu c21edb160b All Boards: move the Search boards input text 2px further down (top: 8px). 2026-07-18 01:47:16 +03:00
Lauri Ojansivu cb80198d97 All Boards: move the Search boards input text 4px further down (top: 6px). 2026-07-18 01:46:41 +03:00
Lauri Ojansivu 035195f2b4 All Boards: vertically center the Search boards input text.
The search input text sat a little high in the .board-search field; nudge it
down (position: relative; top: 2px) so it reads as vertically centered.
2026-07-18 01:45:47 +03:00
Lauri Ojansivu 197254fe16 All Boards: transparent board tile drag-handle background (icon only).
The desktop board-handle had a dark rgba(0,0,0,0.4) background box; make it
transparent so only the drag-handle icon is visible. The hover highlight is
kept. Guarded in tests/addBoardTileHeight.test.cjs.
2026-07-18 01:43:32 +03:00
Lauri Ojansivu aafe71fd19 All Boards: move board tile drag handle to the right middle.
The desktop board-handle was top-centered (top:4px; left:50%; translateX);
move it to the right middle (top:50%; translateY(-50%); inset-inline-end),
matching the mobile/small-screen handle position. Guarded in
tests/addBoardTileHeight.test.cjs.
2026-07-18 01:42:47 +03:00
Lauri Ojansivu 15e756f4e0 Header/All Boards: reorder Multi-Selection/Sort/Search and swap mobile-toggle/zoom.
- All Boards path header: order is now Multi-Selection, then Sort, then Search
  boards (Search moved to the right of Multi-Selection; Multi-Selection moved to
  the left of Sort). Guarded in tests/addBoardTileHeight.test.cjs.
- Top header bar: swap the positions of the mobile/desktop toggle and the zoom
  controls (mobile toggle now right after the logo, zoom later). Both kept as
  ungated direct children (zoom is no longer hidden when the logo is hidden).
2026-07-18 01:41:48 +03:00
Lauri Ojansivu 97b0240ca4 Board header: icons only — always hide button text labels.
Make the board-header button text labels (Private, Muted, Sort, Filter,
Swimlanes, Multi-Selection, ...) always hidden (was only under a max-width
media query), so the header shows icons only. The board title and the star
count (.board-star-counter) are not button labels and stay.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 01:27:35 +03:00
Lauri Ojansivu 68723ff43b Board header: remove the Board Settings cog (it is already in the right sidebar).
The #6465 one-click board-settings cog (js-open-board-menu) sat just left of the
sidebar hamburger, duplicating Board Settings which is reachable from the right
sidebar. Remove the cog from boardHeader.jade, its now-dead click handler from
boardHeader.js, and the unused .js-open-board-menu header CSS rule. The sidebar
board-menu button (sidebar.jade/js) is unchanged, and the hamburger + separator
remain.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 01:14:28 +03:00
Lauri Ojansivu cfd53cc969 Board header: hide button text labels on narrow windows (icons only).
When the window is not wide enough, the 2nd header bar button labels (Private,
Muted, Sort, Multi-Selection, ...) wrapped to a second row. Add a max-width:
1200px media query that hides the .board-header-btn text spans (show icons
only), keeping the board title and the buttons on one line. The star count
(.board-star-counter) is kept.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 01:05:15 +03:00
Lauri Ojansivu bd72f5d64c Board header: hamburger right gap matches the user-name gap (~8px).
Previously the sidebar hamburger sat 12px from the right edge (2px margin +
10px bar right padding), more than the ~8px gap to the right of the user name
in the top bar (.header-user-bar-name margin-inline-end: 8px). Remove the board
header bar right padding and give the hamburger margin-inline-end: 8px, so its
right-edge gap is the same small amount as the user name.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 00:57:23 +03:00
Lauri Ojansivu e33e50d9c8 Board header: remove empty space around the sidebar hamburger button.
The hamburger used the general .board-header-btn margin: 0 12px, leaving a
button-sized empty gap on each side. Tighten its side margins to 2px (and the
preceding cog button right margin to 2px) so the hamburger sits snugly at the
right edge with no wasted space to its left or right.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 00:53:33 +03:00
Lauri Ojansivu 7718118654 Board header: keep the sidebar hamburger pinned to the right edge at all widths.
The board header bar (Swimlanes + Lists views) used a float layout: the right
button group (which ends with the js-toggle-sidebar hamburger) was float:
inline-end. As the window width changed, the floated groups wrapped and the
hamburger drifted to the middle of the bar. Make #header-main-bar a flex row
(align-items:center, flex-wrap:wrap), make the button groups flex (their
buttons float is ignored inside a flex container), and pin the right group with
margin-inline-start:auto so it always hugs the right edge (or the right of its
row when wrapped).

Guarded by tests/headerHamburger.test.cjs.
2026-07-18 00:49:29 +03:00
Lauri Ojansivu 772ae061f1 Change Color popup: immediate apply (no Save), left-aligned titles, title, spacing.
Feedback fixes to the Select Color picker:
- Apply IMMEDIATELY on clicking a swatch (and when a custom-color wheel is
  committed) via a shared applySelection(); removed the Save button/handler.
- Category titles (Flat/Clear/Dark/Special) are left-aligned and clear: both,
  so they drop below the previous group swatches (which float: inline-start)
  instead of the 2nd/3rd/4th title sitting mid-row.
- Remove the empty band above the first (Flat) title (first-child margin-top:0)
  and tighten the Default row.
- Member Settings Change Color popup now has a header: added changeColorPopup-title
  ("Change Color" / fi "Vaihda väri"), matching Board Settings.

Tests updated in tests/themeColorPicker.test.cjs.
2026-07-18 00:45:33 +03:00
Lauri Ojansivu c6f960bbe1 rebuild-wekan.sh: robust CURRENT-IP detection (fix http://:3000 Invalid URL).
The dev-server CURRENT-IP options detected the IP with
  ip a | grep noprefixroute | ... | grep 192.
which requires the noprefixroute flag AND a 192.x subnet. On hosts without
either (e.g. arm64 Ubuntu on a 10.x/172.x network) it returned empty, so
ROOT_URL became http://:3000 and Meteor crashed with "TypeError: Invalid URL".

Detect the primary IP subnet-agnostically via the default route
(ip route get 1.1.1.1 -> src), fall back to hostname -I, then to localhost so
the URL is always valid. macOS uses ipconfig getifaddr en0/en1 with a localhost
fallback. Applied to both CURRENT-IP dev-server options.
2026-07-17 23:52:52 +03:00
Lauri Ojansivu ac20cd6442 All Boards: hide Home-board icons and disable setting a Home board (#2220).
Remove the fa-home Home-board toggle from the board tiles (boardsList.jade),
its click handler and the isDefaultBoard tile helper (boardsList.js), so a
Home board can no longer be set from the All Boards page. The model helpers,
server toggleDefaultBoard method and the login redirect are left intact but
are no longer reachable from the UI. Test flips to guard the disabled state.
2026-07-17 23:50:39 +03:00
Lauri Ojansivu c6aa769bc0 All Boards: Multi-Selection button looks the same as in Swimlanes View.
The All Boards button showed only an icon (span.emoji-icon > fa-check-square-o)
with the reset X as a sibling, while the Swimlanes View (boardHeader.jade)
shows the check icon + a "Multi-Selection" text label with the reset X nested
inside. Match the All Boards markup to the Swimlanes View (direct icon + text
label + nested reset with fa-times-thin), and add stopPropagation to the reset
handler so the now-nested X does not bubble to the activate handler and
immediately re-enable selection.

Guarded by tests/multiSelectionButton.test.cjs.
2026-07-17 23:48:26 +03:00
Lauri Ojansivu 7699cc1e90 All Boards: My Boards bar wraps so Sort/Multi-Selection/Clear stay visible.
The .boards-path-header was a single-row flex (justify-content: space-between,
no flex-wrap) and .path-right (search box + Sort / Multi-Selection / Clear)
also did not wrap, so narrowing the window — especially with Multi-Selection
on, whose hint widens .path-left — pushed those buttons off/hid them. Add
flex-wrap: wrap to the bar, .path-left (min-width:0 so it can shrink; hint
wraps under the title) and .path-right (buttons wrap among themselves), so
everything drops to a second row and stays visible.

Guarded in tests/addBoardTileHeight.test.cjs.
2026-07-17 23:32:31 +03:00
Lauri Ojansivu 1312fefb12 All Boards: remove empty .board-list-header dead grey space above the layout.
The .board-list-header div (a leftover zoom-controls container) rendered
nothing but still reserved vertical space via margin: 1vh 0 2vh 0 + padding:
1vh 0 — an empty grey band above the boards layout, between the top and the
left menu / "My Boards" bar. Removed the empty element from boardsList.jade
and its now-unused CSS rule, so .boards-layout is the first child of .wrapper.

Guarded in tests/addBoardTileHeight.test.cjs.
2026-07-17 23:27:01 +03:00
Lauri Ojansivu 1008561517 All Boards: + Add Board / + Add Template Container tile same height as board icons.
The grey add-tile (.js-add-board .label, shared by both "+ Add Board" and
"+ Add Template Container") overrode min-height:100px, line-height:56px and
padding:36px 8px 32px 8px — taller than the #6465-thinned board tiles
(.board-list-item: min-height:72px, padding:24px 8px 18px 8px). Match the
board-tile box model so the grey background is the same height as a board icon.

Guarded by tests/addBoardTileHeight.test.cjs (asserts the add-tile min-height +
padding equal the board tile, and the old oversized values are gone).
2026-07-17 23:19:23 +03:00
Lauri Ojansivu 1cd32001d4 Select Color: global override applies everywhere + visible grouped swatches (#5778).
Two fixes from feedback that picking clearblue "did not change" and the
dropdowns were hard to use:

1) The global theme override now wins EVERYWHERE, including board pages. Before,
   the <body> autorun suppressed it whenever a board was open (onBoard gate), so
   changing the color while viewing a board did nothing. Now a set global override
   is applied regardless of board context, and #header / #header-quick-access
   (header.jade) and .board-wrapper (boardBody.jade) prefer the global override
   over the per-board color. Unset it to return to per-board colors. When no
   global is set, an open board still exposes its own custom colors.

2) The picker no longer uses category/theme dropdowns (colors were invisible).
   It now shows the actual color SWATCHES with their names, grouped by category,
   with the category name as a header above each group. The flat/clear color
   wheels still appear once such a color is selected; custom-capability is derived
   from the selected color. customTheme.css styles the group labels.

Tests updated: themeColorPicker/globalThemeColor guards for grouped swatches and
the global-wins-everywhere behaviour.
2026-07-17 23:12:55 +03:00
Lauri Ojansivu 0535215225 Fix add-list: Save did nothing (Match failed) + empty-swimlane + button (#6465).
Two fixes to the #6465 inline Add List composer:

1) Save button did nothing. The inline composer calls createListAfter WITHOUT
   nextListId, but the server check() used Match.OneOf(String,null,undefined)
   (not Match.Optional) for it. Meteor check throws "Match failed [400]" on a
   MISSING object key unless the sub-pattern is Match.Optional, so every inline
   add-list 400'd. The omittable params (swimlaneId/afterListId/nextListId/type)
   are now Match.Optional, so callers may omit them.

2) Empty swimlane/board now shows a + button, and the create-list form appears
   only after clicking it (a swimlane that already has lists keeps using the
   per-list-header add-list button — no + button needed). The reveal is scoped
   to the clicked swimlane via a wekan-add-list-empty Session flag, cleared on
   save/close.

Tests: models/lib/createListParams.js + tests/createListParams.test.cjs mirror
the method's param contract (incl. Meteor's missing-key rule) with the exact
client shapes + negatives, and a source guard that the check uses Match.Optional;
tests/upcomingUiGuards.test.cjs gains guards for the empty-state + button reveal.
Wired into test:unit:node.
2026-07-17 23:01:52 +03:00
Lauri Ojansivu 8aacc22360 Test: de-flake webkit swimlane-add popup wait (6s -> 15s).
specs/13-swimlanes.e2e.js waited only 6s for the swimlane action .js-pop-over
to appear. Under the full parallel suite that occasionally exceeds 6s on
webkit (the popup opens fine, ~3s, when the test runs alone), so the test
flaked. Bump to 15s to match the suite standard popup-visibility wait; a
genuinely broken popup still fails.
2026-07-17 22:47:37 +03:00
Lauri Ojansivu 437c64c9a0 Fix #4759 regression: uiTextColor/uiTextBgColor custom() rejected every user insert.
The optional profile.uiTextColor / profile.uiTextBgColor schema fields ran
their custom() hex validator on EVERY user insert, including when the field
was absent. /^#..$/.test(undefined) is false, so the validator returned
"notAHexColor" and collection2 threw ValidationError for any user with no
custom color set — surfacing as a SyncedCron fatal unhandledRejection and
blocking user creation (seen in ../log test-server.log; also cascaded into
ECONNREFUSED e2e failures as the server destabilized).

Guard both custom() validators to return undefined when the value is unset
(undefined/null/empty), matching the existing list-color validator. Add a
source-guard test so the guard cannot silently regress.
2026-07-17 22:05:18 +03:00
Lauri Ojansivu 061eba8b7a Finnish: translate newest UI strings (theme, font, size, colors, Sandstorm).
Translate the strings added this cycle in fi.i18n.json, reusing existing
terminology (väri, taulu, lista, uimarata, fontti, teema, Oletus): the Select
Color theme categories, the Member Settings font / size / text + background
color pickers, the default-board tooltips, and the list-move confirmation.
Fixes theme-category-clear, which had been auto-translated to the verb
"Tyhjennä" (empty) instead of the theme sense "Kirkas". font-preview-text uses
the standard Finnish pangram so the preview shows ä/ö.
2026-07-17 21:48:12 +03:00
Lauri Ojansivu 176c5ef2d8 Updates 2026-07-17 21:44:36 +03:00
Lauri Ojansivu 9a0c0613a5 Sandstorm: auto-open a single-board grain (no save) (#2220).
Restore the old "a grain opens straight into its board" behaviour as a
convenience, WITHOUT persisting anything. On the Sandstorm home route only:

- a saved Home board (explicitly chosen by the user) always wins -> redirect;
- else, once the boards subscription has loaded, exactly ONE board -> just
  open it (nothing is saved);
- else (zero or many boards, no chosen Home board) -> stay on All Boards.

Choosing a Home board stays a separate, explicit action (the All Boards home
toggle, #2220) — the auto-open never writes it.

The redirect is reactive (Tracker.autorun over a boards subscription), so it
waits out Sandstorm asynchronous grain login (Meteor.userId lands late) and
board loading instead of firing once too early. Runs at most once per page
load, and only when isSandstorm.

- models/lib/sandstormAutoOpen.js: pure, unit-tested decideSandstormAutoOpen()
  (+ tests/sandstormAutoOpen.test.cjs, wired into test:unit:node).
- config/router.js: startSandstormAutoOpen() reactive redirect, gated to
  Sandstorm; non-Sandstorm keeps the existing one-shot default-board redirect.
2026-07-17 21:40:37 +03:00
Lauri Ojansivu 22c7d5376b Member Settings / Font / Color: custom text + background color (#4759).
The Font popup now has a color wheel for the text color and one for the text
background color, each with an Unset button to go back to default. Values are
free choices (native <input type=color>), validated server-side as #rrggbb
hex (models/lib/uiFonts isHexColor6) so only a strict hex ever reaches a CSS
value — no injection surface. An unset/invalid value clears that color.

- profile.uiTextColor / profile.uiTextBgColor schema (hex-validated) + getters;
  setUiColors(textColor, bgColor) method ($set valid hex, $unset otherwise).
- changeFontPopup: two color wheels + reset buttons; live preview reflects
  font, size, text color and background color.
- Applied UI-wide via --wekan-ui-text-color / --wekan-ui-bg-color CSS variables
  + has-ui-text-color / has-ui-bg-color body classes (uiFont.js + uiFont.css).
- i18n text-color / text-background-color; tests extended (hex + negatives +
  source guards).

Part of #4759.
2026-07-17 21:15:51 +03:00
Lauri Ojansivu 3c9524f61a Member Settings / Font: pick an installed font + preset size (#4759).
New "Font" entry in the member menu opens a popup with a dropdown of fonts
DETECTED as actually installed in the browser (no free typing) and a Font
size dropdown of sensible presets (Smaller..Largest, no typed numbers). Both
have a Default option that unsets the custom choice.

Security: font names and size keys are validated server-side against a
curated whitelist / preset list (models/lib/uiFonts.js), so only a known safe
font name and a preset percentage ever reach the DOM. Labels are plain text
(Blaze-escaped) — never HTML or markdown. No CSS-injection surface.

- models/lib/uiFonts.js: UI_FONTS whitelist + SAFE_FONT_RE + isKnownFont /
  fontFamilyValue and UI_FONT_SIZES presets + isKnownFontSize / fontSizeValue.
- client/lib/fontDetector.js: canvas width-comparison detection of installed
  fonts, filtered to the whitelist.
- profile.uiFont / profile.uiFontSize schema + getters; setUiFont /
  setUiFontSize methods (validated, null/default unsets).
- changeFontPopup (font + size dropdowns, both with Default) wired in
  userHeader.js; applied UI-wide via CSS variables in uiFont.js + uiFont.css
  (marker body classes), wired into client/features/main.js + client/styles.js.
- i18n change-font/font/font-default/font-size(+presets); tests/uiFonts.test.cjs
  (pure + negatives + source guards) wired into test:unit:node.

This is a Member Settings feature (not an Admin Panel option).

Fixes #4759.
2026-07-17 21:15:39 +03:00
Lauri Ojansivu d0a1e43f36 Select Color: two-level dropdown picker + custom colors (#5778).
Both Board Settings and Member Settings Change Color now use a shared
category -> theme two-level dropdown picker with custom color wheels for the
flat (1 color) and clear (2 colors, a color slide) categories; dark/special
are fixed. Custom colors are validated server-side as #rrggbb, stored in
board.customThemeColors / profile.globalThemeCustomColors, and applied as
CSS variables (--theme-accent / --theme-accent-2) consumed by customTheme.css
to recolor the header, primary buttons and sidebar button.

- client/components/main/themeColorPicker.{jade,js}: shared picker, scope
  "board" or "global", reusing the #5514 native color wheel + themeCategories.
- board.setColor(color, custom) + setGlobalThemeColor(color, custom): validate
  via isValidCustomColors (flat/clear + count + hex) before storing.
- boardChangeColorPopup and changeColorPopup now render the shared picker;
  removed their old swatch-list helpers/events.
- globalThemeColor.js: apply custom colors as :root CSS variables (board own
  colors on a board, else the user global override) + marker body classes;
  customTheme.css consumes them.
- Wire globalThemeColor.js / themeColorPicker into client/features/main.js and
  customTheme.css into client/styles.js. This also fixes globalThemeColor.js
  never having been imported (so the #5778 override now actually loads).
- i18n theme / theme-category[-flat/clear/dark/special]; tests
  themeColorPicker.test.cjs + updated globalThemeColor.test.cjs.

Fixes #5778.
2026-07-17 20:58:39 +03:00
Lauri Ojansivu 49285dad19 Select Color: theme categories core + design doc (docs/Theme/Theme.md).
Partition the ~19 board/global theme colors into flat / clear / dark /
special and define where custom colors are allowed (flat = 1 accent,
clear = 2 for a color slide, dark/special = fixed none). This is the shared
source of truth for the redesigned Select Color picker (two-level dropdowns
+ color wheel) used by both Board Settings and Member Settings.

- models/lib/themeCategories.js: THEME_CATEGORIES + categoryOf /
  colorsInCategory / allowsCustomColor / customColorCount /
  isValidCustomColors (validates category + count + #rrggbb hex).
- tests/themeCategories.test.cjs (wired into test:unit:node): behaviour +
  negatives + a source guard that the partition exactly equals
  config/const.js ALLOWED_BOARD_COLORS.
- docs/Theme/Theme.md: full design — categories, two-level dropdown UX,
  custom-color rules, CSS-variable rendering plan, storage
  (board.customThemeColors / profile.globalThemeCustomColors), and
  server-side validation.

Completes the global per-user theme override (Member Menu / Change Color)
begun in the previous commit.

Fixes #5778.
2026-07-17 20:46:10 +03:00
Lauri Ojansivu eab1546b88 Member Menu / Change Color: global per-user theme override (#5778).
A new "Change Color" entry in the member menu works like Board Settings /
Change Color, but is a per-user GLOBAL override saved to the profile
(profile.globalThemeColor). The chosen color theme applies to the whole UI —
All Boards, Search All Boards, Admin Panel, My Cards, etc. — via a
board-color-<name> class on <body> and the header, so a user can pick a dark
theme (dark/midnight/moderndark/exodark/cleandark) or any other theme
everywhere. The first popup entry clears the override.

- models/users.js: profile.globalThemeColor field + getGlobalThemeColor /
  globalThemeColorClass / setGlobalThemeColor helpers.
- server/models/users.js: setGlobalThemeColor method, validated against
  BOARD_COLORS (rejects unknown colors so no arbitrary CSS class is injected)
  and auth-guarded; null/empty clears it.
- client/components/main/globalThemeColor.js: Tracker autorun that adds the
  class to <body> on non-board pages; on a board the board own color wins, so
  the global class is cleared there to avoid two themes clashing.
- header.jade: #header and #header-quick-access fall back to the user global
  theme class when not on a board (covers the #header-specific color rules).
- Change Color menu entry + changeColorPopup (color swatches + clear option),
  wired in userHeader.js.
- i18n change-color / theme-default; tests/globalThemeColor.test.cjs wired
  into test:unit:node.
2026-07-17 20:32:40 +03:00
Lauri Ojansivu 46924985f8 Soft delete for lists: delete = mark, restorable + undoable (#1023).
Deleting a list no longer destroys it. The list and its cards are marked
(deletedAt/deletedBy/deleteBatchId), hidden from the board, recorded as a
reversible delete in history, and restorable via lists.restore or Ctrl+Z.
This is the first slice of "no permanent delete in ordinary use": physical
removal is limited to GDPR/account erasure and a Global-Admin purge gated by
a new Admin Panel / Features / Delete "Enable permanent delete" toggle
(off by default).

- models/lib/softDelete.js: pure notDeleted/softDeleteSet/restoreModifier/
  canPurge helpers (+ tests/softDelete.test.cjs, wired into test:unit:node).
  softDeleteSet omits falsy actor/batch and restore uses $unset so no null is
  ever written to a Date/String schema field.
- enablePermanentDelete feature flag (models/lib/featureFlags.js) + Settings
  schema field.
- deletedAt/deletedBy/deleteBatchId schema fields on Lists and Cards
  (optional, no null default -> absent means live).
- board/swimlane list render paths filter deletedAt: null.
- server/models/lists.js: shared softRemoveList() + lists.softRemove /
  lists.restore / lists.purge (purge gated on canPurge = isAdmin && flag);
  client delete handler and REST DELETE now soft-remove.
- userPositionHistory undo()/redo() handle actionType delete/restore via a
  batch-consistent _applyDeleted() helper.

Design + planned rollout (other entities, Recycle Bin, admin Delete category
table with GDPR data classes) in docs/Features/Undo/Undo.md.
2026-07-17 20:20:06 +03:00
Lauri Ojansivu 58344db0d7 Undo docs: soft-delete design (delete = mark, never destroy).
Every user-facing delete becomes a soft delete (deletedAt/deletedBy), making
deletions restorable and undoable (#1023). No permanent delete in ordinary
use: physical removal is limited to GDPR/account erasure and an explicit
Global-Admin purge gated behind Admin Panel / Features / Delete ->
"Enable permanent delete for Global Admin" (off by default). Covers scope
(soft vs exempt collections), the softRemove/notDeleted mechanism, cascade
via deleteBatchId, archived-vs-deleted distinction, undo/History integration,
and migration steps.
2026-07-17 20:07:09 +03:00
Lauri Ojansivu e246403bb6 Activities already load progressively (infinite scroll), like visible cards.
The activities publication only ships the most-recent `limit` activities and
the client pages them in via loadNextPage, so large boards stay fast without
deleting activity history. Document this so the request is closeable.

Fixes #2539.
2026-07-17 20:01:52 +03:00
Lauri Ojansivu e9e7071744 Changelog: drop bogus #6483/#6484 issue links (those bugs were reported by email).
The 'Filter by date -> Overdue' and 'list disappears in swimlanes' fixes were
reported originally by email, not as GitHub issues - #6483 and #6484 do not exist
(404). Replace the dead issue links in their Upcoming entries with '(reported by
email)'. The fixes themselves are unchanged.

Thanks to xet7.
2026-07-17 19:55:40 +03:00
Lauri Ojansivu 8287865b23 Tests: cover the remaining Upcoming changes (#2220 default board, #6465 UI, #6478 confirm).
Adds tests (with negative cases) for the Upcoming changes that previously lacked
them:
- tests/defaultBoard.test.cjs (#2220): behavioural toggle/decision logic (set,
  unset the current default, switch to a different board; negatives: nothing is
  the default when unset, B is not the default when A is) + source guards that
  the schema, model + server toggleDefaultBoard, once-per-session router redirect,
  and board-tile toggle are all wired.
- tests/upcomingUiGuards.test.cjs: source guards for the #6465 Add List header
  button + inline composer (leading column replaced by empty-state gating),
  minicard white-space (min-height:0) and swimlane-header zoom clip
  (overflow:visible/height:auto), and the #6478 touch confirmation before a
  cross-swimlane list move (gated on touch/small screen; declining aborts).

The other Upcoming changes already had tests (filterDateNull, listMoveSwimlane,
cardDragSwimlaneScrollRefresh, popupAfterConfirm, undoRedoSelection,
listWidthDefaults, ferretdb* in the FerretDB repo).

Fixes #2220. Fixes #6465. Fixes #6478. Fixes #6479.

Thanks to xet7.
2026-07-17 19:48:53 +03:00
Lauri Ojansivu e8ced84172 Feature #2220: mark a board to open automatically after login (home board).
On the All Boards page each board tile gets a home (house) toggle next to the
star. Marking a board sets it as the user's default 'home' board; the next login
(or reload) lands on it instead of All Boards. Clicking again clears it.

- profile.defaultBoardId (per user) + getDefaultBoardId/isDefaultBoard/
  toggleDefaultBoard model helpers + a toggleDefaultBoard Meteor method (mirrors
  toggleBoardStar).
- home route redirects to the default board ONCE per session (so clicking All
  Boards afterwards stays on the list; honoured again next login/reload); by id
  so it works before the board subscription loads; graceful fallback if the board
  is gone.
- board tile .js-set-default-board toggle + isDefaultBoard helper; i18n
  set/unset-default-board-title.

Thanks to xet7.
2026-07-17 19:38:19 +03:00
Lauri Ojansivu 9be41ab34b Docs: unify undo/redo into one universal change-History (every change) with many scoped views.
Per request, the History design now records EVERY change a user makes (not just
card groups) in one append-only changeHistory store, and undo/redo becomes the
keyboard front-end to it (restore the current user's most recent change) rather
than a separate feature - superseding userPositionHistory.

Same table + restore is surfaced from many menus, each just a scope filter over
the one store: card group, whole card, Member settings (per-user), Board Settings
(per-board), Swimlane menu, List menu, and any future entity menu. Scopes nest
(card in list in swimlane in board) via id-column ORs. Added a scoped-views
section, the scope/scopeId page() params, per-scope permissions, and updated the
phasing (migrate position undo/redo onto changeHistory first). Undo.md now points
at History.md as the source of truth for the unified direction.

Thanks to xet7.
2026-07-17 19:27:00 +03:00
Lauri Ojansivu f3354f5cc8 Docs: undo/redo + high-impact confirmation feature doc.
docs/Features/Undo/Undo.md documents the #6478 feature: position-move undo/redo
(card/list/swimlane) via userPositionHistory with an undone/undoneAt redo stack,
undoLast/redoLast methods, Ctrl+Z/Ctrl+Y bindings, and the touch confirmation for
cross-swimlane list moves. Records the inert-recording bug (collection referenced
as an un-imported global), the write/read wiring, retention, v1 limitations
(positions only; content undo is the separate History feature) and an extension
checklist.

Thanks to xet7.
2026-07-17 19:17:10 +03:00
Lauri Ojansivu 3d1276fc1f Docs: design for per-group card History (view + restore).
Detailed design doc at docs/Features/History/History.md for the requested card
details 'History' feature: per-group history popup with a searchable, paginated
(current page only), per-contributor change table and restore.

Key point: WeKan's Activities log records what happened but NOT before/after
values, so restore needs a new append-only cardGroupHistory content-versioning
collection - the doc specifies the model, write/read (paginated+searchable+
per-user) methods, the 2-pane UI (History + contributor avatars | table with
select/change-type/content/datetime, search+pagination+restore bar), RTL
mirroring, restore semantics (re-logged to both source and restorer; history is
append-only), security, and a phased rollout starting with Description.

Thanks to xet7.
2026-07-17 19:14:40 +03:00
Lauri Ojansivu 765d04bf7c #6465: Add List as a per-list header button + inline composer, not a standing column.
Swimlanes/Lists views no longer render a permanent Add List composer column.
Each list header now has a far-right add-list button; clicking it opens the Add
List composer as a column immediately AFTER that list in the DOM - which appears
to the right of the list in LTR and to the left in RTL (the lists lane is a flex
row that mirrors under dir=rtl). An empty swimlane/board shows the composer at the
start so the first list can still be added.

- new always-open addListInline template (title input + save + close), submit ->
  createListAfter (afterListId = the clicked list, swimlaneId = the displayed
  swimlane recorded from the DOM by the header button).
- Session 'wekan-add-list-after'/'wekan-add-list-swimlane' drive which list the
  composer opens after; isAddListAfter/swimlaneHasNoLists/boardHasNoLists helpers.
- leading +addListForm removed from the non-mini swimlane and listsGroup
  branches; mini-screen path unchanged.

First cut - desktop/main views; needs live verification of styling, RTL and the
empty-board case.

Thanks to csonkaoszimt (report) and xet7.
2026-07-17 19:12:45 +03:00
Lauri Ojansivu 4ca33d29f7 #6478: undo/redo for card/list/swimlane moves + confirm high-impact list move on touch.
A user accidentally dragged a list into another swimlane on mobile (merging it
with an existing list) and spent ~15 min recovering - no confirmation, no undo.

Undo/Redo: the userPositionHistory collection modelled this but was NOT actually
recording (trackChange guarded on an un-imported global) and had no redo or key
bindings. Now:
- list moves are recorded in updateListSort (import UserPositionHistory so the
  guard passes); card moves already had the call.
- add a symmetric redo() model method + an undone/undoneAt redo stack; add
  userPositionHistory.undoLast/redoLast methods (a new action clears the redo
  stack via trackChange).
- wire Ctrl+Z / Ctrl+Y (and Cmd variants) in client/lib/keyboard.js; disabled
  inside inputs/textareas so native text undo is unaffected.
- pure selection logic models/lib/undoRedoSelection.js + tests.

Confirmation: on touch / small screens, moving a list to a DIFFERENT swimlane
(which re-homes every card) now asks for confirmation; a decline reverts the
drag. Same-swimlane reordering is unaffected.

Thanks to mueschel (report) and xet7.
2026-07-17 18:44:14 +03:00
Lauri Ojansivu f6f021f8cc Fix #6479: confirmation dialogs (remove member, etc.) did nothing on confirm.
Clicking a .js-confirm button silently did nothing. Popup.afterConfirm() stashed
the pending action as a field on the Blaze DATA CONTEXT and the confirm button
read it back as this.__afterConfirmAction. But the context is a
ReactiveCache/Minimongo document (a board member sub-doc), and Blaze re-renders
the confirmation popup with a fresh/immutable copy of that context, so the field
was gone by click time - so every afterConfirm dialog (remove member from board,
etc.) no-oped.

Store the action on the Popup INSTANCE (Popup._afterConfirmAction) instead, which
survives the re-render; it is still invoked with the confirmation popup's own data
context as this, so this.userId keeps working. The .js-confirm handler also guards
against a missing action instead of throwing. Test tests/popupAfterConfirm.test.cjs
with a negative case reproducing the old context-mutation failure.

Thanks to mueschel (report) and xet7.
2026-07-17 18:29:28 +03:00
Lauri Ojansivu 26319c974b Updated ChangeLog. 2026-07-17 18:17:18 +03:00
Lauri Ojansivu 50dd05ceee Updated Sandstorm Backup docs.
Thanks to robertdahlem and xet7 !

Fixes #6475
2026-07-17 18:13:53 +03:00
Lauri Ojansivu 6d5a594b07 Fix #6477: card drag between swimlanes broke when lists are long/overflowing.
Dragging a card from one swimlane to the SAME list in another swimlane dropped
it back in the source swimlane, especially with long overflowing lists. The card
sortable's sort handler auto-scrolls the board (#443) via .js-lists scrollLeft /
.board-canvas scrollTop, but jQuery UI sortable caches container/item geometry at
drag start and only re-caches on its own placeholder moves - never on our manual
scroll. Long lists are exactly what triggers auto-scroll, so the drop resolved
against a stale geometry map and the stop handler's ui.item.parents('.swimlane')
persisted the wrong swimlane.

Re-cache positions with sortable('refreshPositions') whenever the sort handler
actually scrolled - complementing the #2769 DOM-mutation refresh in listBody.js,
which does not fire on scroll. Source-guard test
tests/cardDragSwimlaneScrollRefresh.test.cjs.

Thanks to mueschel (report) and xet7.
2026-07-17 18:04:36 +03:00
Lauri Ojansivu ae29555862 Test: update fixed-list-width e2e for the lowered 200px minimum (#6465).
The #6465 density change lowered MIN_LIST_WIDTH 270->200 and the default
272->220, but specs/38-fixed-list-width.e2e.js still asserted the old 270 floor:
setFixedListWidth(200) was expected to be REJECTED (it is now valid), and the
anonymous default was expected to be 272. This failed on chromium/firefox/webkit.

Use 150 as the too-small value (below the new 200 floor), update the client MIN
mirror to 200 and the default to 220, and add a POSITIVE case that a width of
210 (>=200 but below the old 270) is now accepted.

Thanks to xet7.
2026-07-17 18:00:40 +03:00
Lauri Ojansivu c4784225d5 Changelog: move post-v9.99 changes to an Upcoming section above v9.99.
v9.99 was released with the LDAP connection-leak fix, the bundled-FerretDB pool
note and the snap #6476 wait-loop diagnostics. Everything committed after the
v9.99 bump - the QEMU snap build, the #6465 UI density/minicard/dock/swimlane
changes, the #6483 date-filter fix, the #6484 list-move fix, and the #6476
root-cause (FerretDB orphaned-table crash loop) - now sits under a new
# Upcoming section above v9.99 instead of being backdated into v9.99.

Thanks to xet7.
2026-07-17 17:03:13 +03:00
Lauri Ojansivu 5730f7dcc5 Changelog: bundled FerretDB now pushes date range filters down too.
Updates the #6483 note: label ($in), title ($regex) AND date range
($gt/$gte/$lt/$lte) filters are all now pushed down to SQLite in the bundled
FerretDB, so none of them full-scan the board. Dates use SQLite's ->> accessor
for numeric comparison of the stored millis, verified end-to-end.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu b29a47778f Changelog: bundled FerretDB now pushes label ($in) and title ($regex) filters down.
Notes that the filter slowness flagged earlier is addressed in the bundled
FerretDB, which now pushes {labelIds: {$in: [...]}} and literal {title: {$regex}}
down to SQLite instead of scanning + matching every card's sjson in Go.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu 7b7fada1b8 #6465: swimlane header bar no longer clips at non-100% board zoom.
At e.g. 80% zoom (transform: scale on .board-wrapper) the gray swimlane header
(.swimlane-header) looked cut off: it had overflow: hidden with a fixed
min-height, so once the scaled layout rounded, the bar/text was sliced. Use
overflow: visible + height: auto (keeping the min-height floor) so the header
grows to fit and cannot clip vertically; short swimlane names are unaffected and
a long name wraps instead of being cut.

Speculative CSS fix - verify in the running app at 80% (and check 50%/125%/200%).

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu 41c747ef5f Changelog: #6476 real root cause (FerretDB orphaned-table crash loop) and the bundled FerretDB fix.
Operator logs (uusijani, a1bert01) showed FerretDB was listening and WeKan
connected, then a startup SyncedCron upsert hit FerretDB error
'table accountslockout.connections_<hash> already exists', which became an
unhandledRejection and exited the process; systemd crash-looped it so the HTTP
port never came up. The real fix is in the bundled FerretDB (collectionCreate
now uses CREATE TABLE/INDEX IF NOT EXISTS to re-adopt an orphaned table). The
snap wait-loop timeout/diagnostics remain useful for the separate 'FerretDB
genuinely not ready' case.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu 99299c2a91 #6465: denser defaults - thinner lists (220px), board tiles and card dock (520px).
Several users reported 9.x wastes space vs the older 6.09 layout. Tighten three
density defaults (all overridable; user customizations untouched):

- List width default 272 -> 220px (models/lib/listWidth.js, models/lists.js),
  minimum 270 -> 200px here and in the server check (server/models/users.js).
  Lists already render the same width by default (resolveListWidth) on private
  and public boards; now narrower so more fit. Test updated.
- Board tiles (All Boards page): min-height 100 -> 72px, vertical padding
  36/32 -> 24/18px (boardsList.css).
- Card-detail right dock max-width 800 -> 520px (and the wide-screen centred
  variant 600 -> 520px) in cardDetails.css, so the opened card no longer takes
  ~1/4 of the screen.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu c29cf3b985 Fix #6484: nudging a list in swimlanes view hid it from other swimlanes.
A board-wide list has swimlaneId === null and renders under every swimlane. The
list-drop handler computed
  isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId
but targetSwimlaneId is always the swimlane the list is shown under, and
originalSwimlaneId is null for a board-wide list, so ANY small drag made this
true - it set list.swimlaneId AND moved every card in the list to that one
swimlane, so the list vanished from all other swimlanes (recoverable only by
restoring a backup).

Also require originalSwimlaneId: board-wide lists stay board-wide on a reorder,
while a genuinely swimlane-scoped list can still be moved between swimlanes. Test
tests/listMoveSwimlane.test.cjs, with a negative control that the old target-only
condition rebound a board-wide list.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu b1d33c5362 Fix #6483: 'Filter by date -> Overdue' listed cards with no due date.
Cards without a date store the field as null (that is what the sidebar's noDate
filter matches: {dueAt: null}). The comparison filters build {$lte: now}
(Overdue/past) and {$gte, $lte} (today/this/next week), and $lte MATCHES null
under both FerretDB and minimongo - null sorts before any date and neither
implements MongoDB type-bracketing - so Overdue selected every dateless card.

DateFilter._getMongoSelector now adds $ne: null to any comparison selector so
only real dates match; the noDate filter (a bare null) is unchanged. Test
tests/filterDateNull.test.cjs, with a negative control proving the old bare
{$lte: now} matched null.

The reported filter SLOWNESS and the title/label over-matching are separate
(FerretDB scanning $regex/$in/range queries not pushed down to SQLite) and need
live reproduction.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu a76f7d28e3 Fix #6465: minicards wasted half their height on empty white space.
The minicard title renders inside a .viewer, which inherited the global
.viewer { min-height: 2.5vh } (client/components/main/layouts.css). On tall
screens that reserved a tall empty band beneath a one-line title, so a large
part of every minicard was blank and fewer cards fit on screen. The minicard
title .viewer now sets min-height: 0 (the same override already applied to the
label .viewer), so a minicard collapses to fit its content.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu d7f36d70a0 Snap release: build ppc64el and s390x under QEMU on GitHub Actions, not Launchpad.
The Launchpad 'snapcraft remote-build' legs for ppc64el and s390x ended in
Launchpad state 'Stopped' with no snap produced, yet remote-build still exited 0;
the job's name-only success check let an empty/stale file through and the upload
died with ''wekan_<v>_<arch>.snap' is not a valid file' (exit 64). Build them
instead with docker/setup-qemu-action + diddlesnaps/snapcraft-multiarch-action on
a normal runner - fast, because the snap is bundle-centric (the wekan part only
downloads the prebuilt wekan-<version>-<arch>.zip and repackages it, no native
compilation).

riscv64 stays on Launchpad (the QEMU snap action does not support it) but now has
timeout-minutes: 180 so a stuck build fails with a clear message instead of
GitHub's silent 6-hour cancel.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
github-actions[bot] 63a8657e0a Bump versions for v9.99 2026-07-17 12:10:21 +00:00
Lauri Ojansivu b846e6d565 Updated ChangeLog. 2026-07-17 15:09:11 +03:00
Lauri Ojansivu 580276079e Fix #6476: snap FerretDB wait loop had no timeout, so WeKan hung with no web port.
With database=ferretdb, snap-src/bin/wekan-control waits for FerretDB to accept
connections before starting node - WeKan does not open its HTTP port until the
database answers. That wait loop had no timeout and no diagnostics, so if
FerretDB never started listening (crashed or arch-incompatible per-arch binary,
locked/corrupt SQLite, or the service left disabled by a failed migration
hand-off), WeKan blocked there forever: the service showed active but the port
never opened and the only symptom was a silent 'FerretDB not ready yet' loop.
uusijani's wekan.log stopped right after the startup env dump, confirming
control never reached node main.js.

Mirror the MongoDB branch: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120,
overridable) print an actionable hint once, then keep retrying - pointing to
'snap logs <snap>.ferretdb', naming the SQLite dir to check for locks/corruption,
the arch-mismatch case, the snap start --enable recovery, and the
'snap set <snap> database=mongodb' fallback. Regression test
tests/ferretdbWaitTimeout.test.cjs, including a negative guard that the wait is
not a silent unbounded loop again.

Thanks to uusijani (report) and xet7 (fix).
2026-07-17 15:04:48 +03:00
Lauri Ojansivu 9a387acce0 Translations: auto-heal English-reverted languages on pull; force-push helpers.
tx pull -f fills any string untranslated on Transifex with the English source, so
languages whose translations live only in git silently revert to English on every
pull. Make the pull self-heal, and fix the push helpers so restored translations
actually upload.

- pull-translations.sh: after the pull, for each language file whose changes are
  ENTIRELY reverts, git checkout HEAD -- <file> to restore the committed
  translations and force-push them back (../tx push -t -l <lang> -f). Files that
  also carry a real new/updated translation from Transifex are NOT auto-healed
  (a whole-file checkout would discard it) and are left for manual handling.

- report-english-regressions.mjs: add a machine-readable --files mode and an
  otherChanges classifier, so only pure-revert files are offered for auto-heal;
  the human report tags mixed files.

- push-translation.sh: add -f (without it the Go tx CLI skips a language whenever
  the local file is not newer than the server copy - push.go shouldSkipPush - so
  a freshly reverted file uploads nothing), plus a usage guard.

- push-copy-en-gb-translation.sh (new): copy en.i18n.json to en-GB.i18n.json
  verbatim and force-push it as the en_GB translation, so British English shows
  as translated instead of reverting to the English source on each pull.

Thanks to xet7.
2026-07-17 14:14:31 +03:00
Lauri Ojansivu 7374b7e00d Fix #6467 #6469: LDAP connection leak exhausted the directory server.
Operators reported that after an update WeKan "dies really quickly", restarts
did not help, and it "kills our openldap server with too many open connections";
others saw logins hang for minutes then fail with "Must be logged in". Every
LDAP login attempt (loginHandler.js) and every background sync tick (sync.js)
created a new LDAP() and connect()ed but NEVER called disconnect() on any path,
so each attempt - including every failed login and every one-minute sync tick -
leaked a socket to the LDAP/AD server until it hit its connection limit and
started refusing connections, taking it and all WeKan logins down.

Guarantee release on every exit path: a shared runWithLdapDisconnect() helper
(connectionGuard.js) disconnects in a finally; the login handler runs its whole
flow through it; background sync() disconnects in a finally; importNewUsers()
disconnects only the connection it owns; and LDAP.disconnect() is a safe no-op
when connect() was never reached. Adds behavioural + negative + source-guard
tests (tests/ldapConnectionRelease.test.cjs, wired into test:unit:node).

Also bumps the bundled FerretDB whose SQLite pool no longer caps MaxOpenConns
at 16 (that starved WeKan's cursor load: minutes-long board loads, failed
logins).

Thanks to xet7.
2026-07-17 13:35:42 +03:00
github-actions[bot] e15c920872 Bump versions for v9.98 2026-07-17 03:16:35 +00:00
Lauri Ojansivu d1ac53b367 Updates 2026-07-17 05:03:22 +03:00
Lauri Ojansivu c951b40e5e Changelog: Upcoming security section for EscapeBleed and RandomBleed.
Document the two GitHub CodeQL code scanning fixes already committed
(alerts #423 js/incomplete-sanitization and #422
js/biased-cryptographic-random) in a new Upcoming section above v9.97,
linked to the wekan.fi Hall of Fame entries.

Thanks to GitHub CodeQL and xet7.
2026-07-17 04:57:32 +03:00
Lauri Ojansivu be6ebb2503 Fix CodeQL js/biased-cryptographic-random in schema upgrade randomId.
256 is not a multiple of the 55-character id alphabet, so the plain
byte % 55 mapping skewed generated document ids toward the first
characters. randomId now uses rejection sampling: bytes at or above the
largest multiple of the alphabet size (220) are discarded and resampled,
so every character is equally likely. Ids stay Meteor-style 17-char and
exact-length for custom lengths. Resolves code scanning alert #422.

Tests: tests/schemaUpgradeSteps.test.cjs gains a rejection-sampling
guard (negative case pins that out-of-range bytes are never wrapped).

Thanks to xet7.
2026-07-17 04:52:30 +03:00
Lauri Ojansivu d6ddcffcc2 Fix CodeQL js/incomplete-sanitization in maximizedCardPosition test.
The CSS-declaration-to-RegExp conversion escaped only parentheses;
escape the full regex metacharacter set (including backslash) instead.
Resolves code scanning alert #423. Test-only, fixed input list, but the
complete escape is correct and matches the pattern already used in
tests/archiveLinkGrouping.test.cjs.

Thanks to xet7.
2026-07-17 04:52:30 +03:00
github-actions[bot] 9ab41c8003 Bump versions for v9.97 2026-07-17 01:42:35 +00:00
Lauri Ojansivu b2284f577e Updated ChangeLog. 2026-07-17 04:41:13 +03:00
Lauri Ojansivu 15adf1bb2d Fix #1289: deleting a user no longer leaves ghost users behind.
Every deletion path (admin removeUser method, self-service account
delete, DELETE /api/users/:userId) removed only the users document,
leaving dangling references everywhere: empty-avatar board members that
could not be removed, stale card members/assignees/watchers, orphaned
avatar files - reproducible for 8 years and reopened in 2025. A
server-only Users.after.remove collection hook now prunes
boards.members/watchers (object-shaped, $pull by userId),
cards.members/assignees/watchers and lists.watchers (string arrays), and
the user's Avatars files, on EVERY path that removes a user; a cleanup
failure can never break the deletion itself. Activities and comments are
deliberately preserved for history - their rendering is already
null-guarded (270eacb20, Playwright-verified).

Tests: tests/userDeletionCleanup.test.cjs (6, positive and negative,
end-to-end against a fake database).

Thanks to chotaire and xet7.
2026-07-17 03:53:09 +03:00
Lauri Ojansivu 17161a8355 Fix #2877: swimlanes no longer jump when starting/ending a card drag.
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 about one row (and back down
on drop), moving the drop target under the cursor mid-drag. The root
cause was proven by frame-diffing the issue's own GIF: the three largest
frame discontinuities are exactly drag start and drop, showing the
composer row vanish and the next swimlane header shift ~23px. The
composer now hides with visibility: hidden, preserving its layout box;
checked multi-selection cards keep display: none intentionally (the drag
payload previews the post-drop list).

Tests: tests/swimlaneDragJump.test.cjs (8, positive and negative,
including a guard that flags any return of the layout-collapsing rule).

Thanks to gerroon and xet7.
2026-07-17 03:44:38 +03:00
Lauri Ojansivu 2c23273c11 Fix #443: auto-scroll the board while dragging a card to an
off-screen list.

The horizontal auto-scroll in the card sortable targeted .board-canvas,
which stopped overflowing horizontally when the swimlane layout made the
.js-lists lane the horizontal scroller - scrollLeftMax was always 0, the
guard compared 0 < 0 and never fired, and ui.helper[0].offsetLeft -= 15
assigned a read-only property. Edge-proximity auto-scroll now drives the
lane actually under the pointer (axis-agnostic, clamped, overshoot-safe
helpers in imports/lib/boardAutoScroll.js), with vertical scrolling kept
on the board canvas.

Tests: tests/boardAutoScroll.test.cjs (15, positive and negative,
including a reproduction that the old no-overflow target can never
scroll).

Closes #603: obsolete - the stale-drop-target cause was removed by
d887e553c (perfect-scrollbar removal, native list scrolling),
ed8471be9 (sortable auto-scroll re-enabled) and jQuery UI 1.14.2, whose
sortable resolves insertion neighbours with live offsets on every
mousemove, so a scrolled target list cannot desynchronize drop targets.

Thanks to anhenghuang, AlexanderS and xet7.
2026-07-17 03:41:20 +03:00
Lauri Ojansivu 25d33cec36 Fix #2345 #2674 #574: rules card title filter, rules REST API, api docs;
Sandstorm username races.

- #2345: the Card Title Filter is now SAVED by every board trigger
  builder (a trigger doc missing the field could never satisfy the
  matcher's $in - those rules fired for nothing), SHOWN in the rule
  details, and ENFORCED for archive/restore triggers by resolving the
  title from the card when the activity lacks it; legacy field-less
  triggers keep matching; the filter popup prefills its current value.
- #2674: rules created via POST/PUT /api/boards/:boardId/rules never
  fired - missing matching fields are now normalized to '*' wildcards
  and trigger/action types validated; the addMember/removeMember rule
  actions no longer crash on unresolvable usernames or member-less
  cards and their writes are awaited. New docs/API/Rules.md (endpoints,
  matching fields, wildcard semantics, the exact two-rule
  add-on-move-to/remove-on-move-from workflow), linked from REST-API.md;
  api.py help now lists the rule commands with the same example.
- #574: Sandstorm unique-username generation probes case-insensitively
  (anchored, regex-escaped) and retries the next number when a
  concurrent insert wins the unique-index race, instead of aborting the
  login hook with E11000.

Tests: tests/rulesCardTitleFilter.test.cjs (12),
tests/rulesApiTriggerNormalize.test.cjs (19),
tests/sandstormUsername.test.cjs (11) - positive and negative cases.

Thanks to InfoSec812, sfahrenholz, mitar and xet7.
2026-07-17 03:36:50 +03:00
Lauri Ojansivu 0f2179988b Fix #619: invitee username collisions no longer fail with a raw 403.
Inviting by email derives the new user's username from the email local
part; a collision with an existing user (cats@foo.com then
cats@facebook.com) made Accounts.createUser throw Meteor's raw
"403 Username already exists", shown to the inviter as just "403". The
username now probes cats, cats1, cats2, ... to the first free variant
(bounded), and exhaustion raises the translated error-username-taken.

Tests: tests/inviteeUsername.test.cjs (11, positive and negative,
including the literal reported scenario).

Closes #804: already fixed - every popup is viewport-clamped with
internal scrolling since ac368de06 (#5667 geometry rewrite: maxHeight
from live viewport space, bounded flex layout) plus the 04eb8a8fb
(#6221) global 90vh clamp; the member picker inherits the shell, so
neither standalone nor the Sandstorm iframe can grow past the viewport.

Thanks to lemoer, ocdtrekkie and xet7.
2026-07-17 03:36:07 +03:00
Lauri Ojansivu d5937f6d38 Fix #1502: date pickers honor the configured default time.
The due-date picker configures defaultTime 17:00 (received/start/end use
now()), but an inverted guard in datePickerRendered applied it only when
the card ALREADY had a date - the one case where it is unnecessary - so
for dateless cards the time input stayed empty and submit stored a
hard-coded 12:00. Empty pickers now pre-fill with the configured
default, submit falls back to it, and existing dates keep their own
time. The 2018 AM/PM display/parse mismatch was already fixed by the
native HTML date/time inputs (79b94824e); week start already honors the
user preference.

Tests: tests/datePickerDefaultTime.test.cjs (10, positive and negative).

Thanks to Vlasterx, saschafoerster, suncobran and xet7.
2026-07-17 03:34:52 +03:00
Lauri Ojansivu 7c0ae509ac Fix #2769 #1992 #2292 #2494 #1853 #1554 #2306 #1437.
- #2769: refresh drag geometry when the DOM changes mid-drag - cards no
  longer drop into the wrong swimlane while someone adds a card.
- #1992: board import restores all five card date fields (and creator)
  with fallbacks; receivedAt/endAt were never imported at all.
- #2292: archiving a swimlane archives its cards (mirroring lists);
  restore brings back exactly the cards archived with it.
- #2494: client noCache card lookup returned a Promise since the
  Meteor 3 port - multi-select board moves wrote sort: NaN everywhere.
- #1853: subtask "View it" falls back to the subtask's boardId when the
  deposit board is not loaded, instead of silently doing nothing.
- #1554: restore the reactive dependency lost in 7673c77c5 so cards
  added after board render accept sidebar label/member drops.
- #2306: "filter to selection" and every bulk action are board-scoped;
  foreign-board cards can no longer be silently archived.
- #1437: new POST /users/logout API endpoint revokes the presented
  resume token, or all tokens with {"all": true}.

All with positive+negative regression tests (see tests/*.test.cjs).

Closes #1389: already fixed - the standalone pencil edit button
(0cc63b810) plus the wrapped viewer (bf2b3b40c) guarantee an edit target
even when a full-width link fills the description.

Thanks to hever, Cactusbone, Vermeille, Vanclief, Miffe, IcedQuinn,
ppouliot, GhassenRjab and xet7.
2026-07-17 03:34:23 +03:00
Lauri Ojansivu 42b4e51f85 Fix #2418: one inlined edit form at a time; no cross-item overwrite.
Since 8c3ce4bb7 (2021) the "close the previous inlined form" call was a
silent no-op: the inlinedForm escape action registers with
enabledOnClick: false and EscapeActions.clickExecute skips such actions,
so clicking another checklist item left both edit forms open - and the
submit handlers find the template's FIRST textarea, so the wrong form's
text was saved over the submitted item. Subtasks reproduced the full
bug; the checklists workaround corrupted the shared open-form tracker so
Escape fell through and closed the whole card details pane. A pure state
manager (client/lib/inlinedFormManager.js) now enforces the single-open
invariant: opening a form closes the tracked previous one (popups
untouched, preserving the 2021 intent); close/destroy only reset the
tracker for the form they own.

Tests: tests/inlinedFormSingleOpen.test.cjs (9, positive and negative,
including the exact reported A->B->C repro).

Thanks to Beebo89 and xet7.
2026-07-17 03:30:07 +03:00
Lauri Ojansivu 66a19c4bd4 Fix #2989: Advanced Filter matches date custom fields.
Two defects: the tokenizer flipped into regex mode on every '/' even
inside quoted strings, so a quoted date like '06/04/2020' broke
tokenizing and the whole filter silently no-opped; and date custom
fields store Date objects while the comparison selectors used strings
and parseInt - equality and ranges could never match, != matched every
card. Dates now parse per the user's date format (day-first formats
respected) into half-open Date-range selectors; non-date fields keep the
legacy selectors unchanged.

Tests: tests/advancedFilterDate.test.cjs (14, positive and negative).

Closes #2521: already fixed since v3.88 (9819c9f80) - the checklist
completed/uncompleted activity text was missing due to an activityType
name mismatch between model and template; the current unified activity
template, i18n keys and helpers render it correctly.

Thanks to k1ng440, ott29 and xet7.
2026-07-17 03:27:33 +03:00
Lauri Ojansivu 5e036dc544 Fix #3826: card drag reorder in lists full of subtask cards.
addSubtaskCard inserted every subtask card with the constant sort -1,
leaving the whole list tied: a drop between two equal sorts computes a
zero increment, the move modifier is empty and Card.move() silently
discards it - the card snapped back, and a multi-selection drop wrote
the SAME sort to every selected card, permanently destroying their
relative order (the data-loss risk in the issue triage). Subtask cards
now append with a unique sort (max+1), and the sortable stop handler
detects degenerate gaps (ties/inversions) and repairs the siblings'
sorts to a strictly increasing order before recomputing the drop index;
healthy lists take the unchanged fast path.

Tests: tests/subtaskCardReorder.test.cjs (14, positive and negative,
including the exact issue scenario and a guard that the old
{base:-1, increment:0} result can never be produced again).

Thanks to jayki and xet7.
2026-07-17 03:21:28 +03:00
Lauri Ojansivu 973c2ad901 Fix #3453 #3199; linked-card custom field crash; pin #3843.
- #3453: publish the FULL subtask ancestor chain, not only direct
  parents - the cross-board full-path label vanished after refresh.
- #3748 (defect half): skip unresolvable custom-field definitions on
  linked cards - phantom empty rows and a template TypeError on every
  card details render. The mirroring itself is by design: linked cards
  are pointers; label/custom-field ids are board-scoped; name-based
  inheritance is the copy feature, not the link feature.
- #3199: group each archived card with its own Restore/Delete links and
  add a clear separator below the group (RTL-safe, theme-neutral).
- #3843: comment-editor uploads and the attachments popup now share one
  null-safe meta builder, and tests pin that comment attachments list in
  the card's Attachments section.

Closes #3748: mirroring is by design (see above); the only actionable
defect is fixed in this commit.
Closes #3378: already fixed - notification_cleanup moved off SyncedCron
to a guarded Meteor.setInterval singleton, and quave:synced-cron 2.4.0
handles the E11000 history collision that percolate 1.5.x missed.
Closes #3276: already fixed by d3625db75 - the spent-time badge helpers
now read the live Cards document reactively, so the overtime color flips
immediately; verified behaviorally against the real component.
Closes #3843: behavior verified present and now regression-locked.

Thanks to MPeti1, peterbecich, fxkr, jghaanstra and xet7.
2026-07-17 03:21:02 +03:00
Lauri Ojansivu dd3f71c951 Fix #4822: maximized card position in Swimlanes view.
The maximized card pane had no position of its own, so it remained an
in-flow item inside the scrolled board canvas and rendered off-screen
after scrolling down; desktop-mode floating-window rules also overrode
every maximize geometry rule, and inline drag offsets survived
maximizing. The pane is now viewport-fixed with insets that win over
both (drag position restores on minimize), RTL-safe. Regression test
with a CSS cascade resolver that fails on the pre-fix stylesheet:
tests/maximizedCardPosition.test.cjs.

Closes #3566: mobile shows one list at a time by design (xet7's own
comments in the issue); Move Card popup is the supported flow, and the
2021-era touch drag glitches were since fixed with drag handles.
Closes #3252: already fixed - comment deletes are guarded in
client/components/activities/comments.js (tagged #3252), checklists in
checklists.js, attachments via #5282.
Closes #3542: no code bug - redirect_uri is the same expression on the
client and server (mismatches come from ROOT_URL/proxy configuration,
per the Bug:Environment-specific label), and the login-style half was
fixed via #5695.

Thanks to pravdomil, 1ubuntuuser, gastoncollaud and xet7.
2026-07-17 03:15:52 +03:00
Lauri Ojansivu 6b9086d2a8 Changelog for #4036 #4043 #4023 #4593; finish #4043 hardening.
- CHANGELOG.md: Upcoming entries for the LDAP group filter fix (#4036),
  invitation code regeneration (#4043), CJK add-card footer wrapping
  (#4023) and team-to-board member sync (#4593), whose code shipped in
  the previous commit.
- server/models/users.js: do not delete the invitation code in
  onCreateUser before the account insert is committed - a failed insert
  (duplicate username/email) burned the code for every retry (#4043).
  Consumption happens after a successful insert; re-invites regenerate
  stale codes.
- tests/teamBoardMemberSync.test.cjs: the #4593 regression tests (13,
  positive and negative) for the team board-member sync helpers.

Thanks to zeisss-mercedes, jkoenig134, yuki-snow1823, Sylvain2703,
szymonsztuka and xet7.
2026-07-17 03:12:43 +03:00
Lauri Ojansivu 9ddbc777dd Startup schema upgrade, self-healing attachments, migration speed; fix
#5695 #4897 #4654 #5174 #4825 #5659 #4730 #4419 #4158 #4560 #4036
#1959 #1971.

- Startup schema upgrade (server/lib/schemaUpgradeSteps.js): on start,
  check that data from EVERY old WeKan version is migrated to the newest
  structure and migrate only what is missing - all platforms, MongoDB and
  FerretDB. Version-gated (marker stores WeKan version + datetime of the
  last re-check; one findOne per boot until a new release), non-blocking,
  live dashboard at /schema-upgrade-status showing the product name, fast
  on big databases (distinct() set-joins, updateMany batches). Steps:
  archived-flag backfill, swimlane structure + visibility rescue
  (#1959 #1971: cards under deleted/archived/foreign swimlanes reassigned
  to a visible swimlane; card-insert hook validates supplied swimlaneIds),
  merge-per-swimlane-lists (v7.98/v8.0x list copies merged back into
  shared lists: no duplicate columns in Lists view, everything visible in
  Swimlanes view, cards keep their swimlane), embedded checklist items,
  customFields boardIds, allows* backfills, members isActive, permission
  lowercase, filesystem path healing.
- Migration speed (5-hour reports): insertMany batches in the text phase,
  preloaded target-record maps in the file phases.
- Snap attachment-repair + FILES_ONLY importer mode (see prior commit
  wiring) extended with CFS FileSystem-store coverage
  (ATTACHMENTS_STORE_PATH era), missing-binary reporting, and
  migrate-gridfs-to-fs.mjs bucket fixes.
- OAuth2/OIDC: OAUTH2_LOGIN_STYLE=redirect honored (#5695); concurrent
  logins no longer contaminate each other's user data (#4897); account
  merge no longer wipes avatars/templates (#4560).
- LDAP: background sync fixed (#4654), password login rejected after LDAP
  migration (#4419), LDAP_ENCRYPTION=true actually encrypts (#4158),
  group filter accepts multiple groups and admits admin-sync groups
  (#4036).
- All Boards tiles: per-list card counts + member avatars restored
  non-reactively (#5174 #4825). Uniform default list widths (#5659).
  Declined invitations no longer resurrect membership (#4730).
- Tests with negative cases for every fix (tests/*.test.cjs; suite green
  except the pre-existing environment-only htmljs failure).

Closes #5600: OAUTH2_CA_CERT already works - the fetch migration
(49a272d6c) applies the CA to all OIDC calls via https.Agent.
Closes #3828: board members seeing all board cards is by design (no
per-card visibility model), and organizations/teams now exist.

Thanks to mueschel, ArturRuta, gerardo-junior, fabianrbz, Miuler,
butteredCat-2021, Griiimm, preciousamorc, farwayer, LeoLu-eng, hnb1,
rangersdo and xet7.
2026-07-17 03:07:59 +03:00
Lauri Ojansivu 65bf215cbe Fix #6473: attachments missing after the MongoDB -> FerretDB migration.
- Migration importers: resolve the CollectionFS GridFS id from
  copies.<bucket>.key too (the REAL old-WeKan layout; only
  original.gridFsFileId / gridFsFileId / copies.gridfs.key were tried,
  none of which exist there) via a shared resolveCfsGridFsId() in
  releases/migrate-mongodb-to-ferretdb.mjs,
  snap-src/bin/migrate-mongo3-to-ferretdb.mjs and
  snap-src/bin/migrate-gridfs-to-fs.mjs. The modern importer now drives
  CollectionFS extraction from cfs_gridfs.<bucket>.files itself, so a
  missing/empty cfs.<bucket>.filerecord no longer silently skips every
  file; binaries without a filerecord are still extracted to disk, and
  filerecords whose binary cannot be located are reported on the
  dashboard instead of dropped.
- Meteor-Files phase: match versions.*.meta.gridFsFileId without a
  storage flag (WeKan's own getFileStrategy rule), handle every version
  (not just 'original'), and add a bucket-driven sweep of <bucket>.files
  by metadata.fileId so binaries are recovered even when the record's
  flags say nothing about GridFS. GridFS-flagged versions with no
  locatable binary are reported.
- mongo3 importer: copy the CollectionFS top-level boardId/cardId/
  listId/swimlaneId/userId into meta on the created records - an
  attachment without meta.cardId shows on no card.
- Admin Panel > Attachments: the Filesystem Storage paths were computed
  from process.env.WRITABLE_PATH IN THE BROWSER, so they always showed
  "/data". New admin-only getAttachmentStoragePaths method returns the
  real Snap-aware paths (shared models/lib/attachmentStoragePath.js),
  also used for the settings document default (was /data/attachments,
  actual Docker path is /data/files/attachments).
- Snap: `snap run wekan.database ferretdb` now says it does NOT migrate
  data, says when nothing was switched, and points at
  `snap run wekan.migrate` for re-running the full migration.
- Tests with negative cases: tests/migrationAttachmentExtraction.test.cjs
  (behavioral resolveCfsGridFsId from all three importers + wiring
  guards) and tests/attachmentStoragePath.test.cjs, wired into
  test:unit:node.
- Companion fix in wekan/FerretDB: accept documents with literal dotted
  field names like MongoDB 3.6+, so migrated documents containing them
  are no longer silently dropped.
- CHANGELOG.md: new Upcoming section with these changes.

If attachments are already missing after an earlier migration, run
`snap run wekan.migrate` - the source MongoDB data was never modified,
and the fixed importers re-create the attachment records and files.

Thanks to mueschel and xet7.
2026-07-17 01:43:53 +03:00
github-actions[bot] e4e73e8b80 Bump versions for v9.96 2026-07-16 17:40:41 +00:00
Lauri Ojansivu c83284811c Updated ChangeLog. 2026-07-16 20:39:07 +03:00
Lauri Ojansivu 643a296595 Fix #6465 follow-ups found by test run 2026-07-16_19-21-02.
The right-docked card details window exposed two real issues in the
Playwright run (03-cards-operations failing in all three browsers):

- Archiving or deleting a card left its id in the openCards session
  list, so the floating details window kept rendering above the board —
  and, docked to the right edge, exactly over the archives sidebar,
  intercepting its Restore/Delete clicks. Both handlers now drop the
  card from openCards and clear currentCard.
- An OPEN board sidebar now stacks above the card window (z-index 2002
  vs 2001), so the sidebar is always usable while a card is open.

Also from the same run: the new one-click cog spec closed its popup with
a keyboard Escape that proved unreliable across engines — it now uses
the popup's own X button (.js-close-pop-over) like 19-accessibility
does. The chromium-only 33-board-domains failure is an unrelated
token-login race in the auth helper (userId mismatch), not caused by
these changes.

tests/uiDensity.test.cjs got guards for both follow-ups (openCards
cleanup in archive+delete, sidebar z-index above the card window).
Updated ChangeLog.

Thanks to xet7.
2026-07-16 20:01:10 +03:00
Lauri Ojansivu 2d76e1cb92 Behavioral tests for the ferretdb-has-data guard.
tests/ferretdbHasData.test.cjs runs the real snap-src/bin/ferretdb-has-data
against crafted directories. This guard gates two critical switches
(migration-control's finish_success and mongodb-control's #6458/#6466
failure handler), so its semantics must never drift. Positive: non-empty
.sqlite, no -wal sidecar required (clean shutdown checkpoints it away),
one good file among empties. Negative: 0-byte .sqlite from a failed
migration, empty/missing directories, -wal/-shm-only leftovers, and a
DIRECTORY named *.sqlite. All eight behaviors verified against the
actual script. Wired into test:unit:node; ChangeLog updated.

Thanks to xet7.
2026-07-16 19:12:59 +03:00
Lauri Ojansivu 4fc918c245 Playwright: fix strict-mode violation from the #6465 header cog; test it.
The 2026-07-16_18-33-28 test run failed one spec in all three browsers:
the #6465 one-click board-settings cog added a second .js-open-board-menu
element, so 03-cards-operations' bare class locator resolved to 2
elements and tripped Playwright's strict mode. The archives test now
scopes the click to the sidebar's instance (.board-sidebar
.js-open-board-menu), keeping its 'from inside the sidebar' intent.

Also added a spec for the new cog itself to 15-board-actions: visible in
#header-main-bar, popup not open before the click (negative), ONE click
opens the real boardMenu popup (.js-open-archives inside), Escape closes
it again.

Thanks to xet7.
2026-07-16 19:09:36 +03:00
Lauri Ojansivu d7ca7360ff Tests for the cpu-exec delivery pipeline; changelog upcoming updates.
tests/cpuExecWiring.test.cjs, with negative cases: every Linux bundle in
release-all.yml embeds cpu-exec + its own arch's static qemu-user (arm64
and the QEMU-built extra arches replace the inherited amd64 one, extra
arches tolerantly), the Windows/macOS bundles strip both Linux-only
files, qemu-user-static is installed in every bundle-building job and
both sandstorm jobs, the Sandstorm .spk ships cpu-exec + qemu via
build-deps.sh (tolerant when qemu is not installed), the Docker
entrypoint and the bundle launcher route ferretdb/node through cpu-exec
while keeping direct-exec fallbacks for bundles that lack it, and the
snap ships cpu-exec via the snap-src helpers part. Wired into
test:unit:node; complements tests/cpuExec.test.cjs (the script's own
behavior).

ChangeLog Upcoming: the regression-tests entry now lists the cpu-exec
wiring test, the subtasks default-board test (#6456) and the FerretDB
fork's integration-test fixes.

Thanks to xet7.
2026-07-16 18:54:28 +03:00
Lauri Ojansivu fcce2e1cea Subtasks: fix addSubtaskCard crash on Meteor 3 server.
Fixes #6456.

The lazily-creating getters for the default subtasks helper board and
its landing list still used the sync Boards.insert / Swimlanes.insert /
Lists.insert / Boards.update APIs that Meteor 3 removed from the server,
so creating a subtask crashed with 'insert is not available on the
server. Please use insertAsync() instead' whenever the ^Board^ helper
board had to be created. The list creator additionally read
getDefaultSwimline()._id, which is a Promise on the Meteor 3 server.

- getDefaultSubtasksBoardId / getDefaultSubtasksListId are now PURE
  getters (no creation), matching the #3868/#2256 rule that only the
  server may create these.
- The server-side lazy creation moved into getDefaultSubtasksBoardAsync
  and getDefaultSubtasksListAsync using insertAsync/updateAsync and
  getDefaultSwimlineAsync — the exact path addSubtaskCard uses.
- The never-called date-settings twins had the same sync calls and no
  server-only guard at all; they are pure getters now too.
- tests/subtasksDefaultBoard.test.cjs guards the async creators and that
  no sync collection writes come back to models/boards.js, wired into
  test:unit:node.
- Updated ChangeLog.

Thanks to sjohnen and xet7.
2026-07-16 18:46:09 +03:00
Lauri Ojansivu a91203b158 Close the issues fixed by ed19445d3 and 794346e39.
That commit's message listed all ten issues after a single Fix keyword,
but GitHub only auto-closes a reference that directly follows its own
closing keyword, so only the first issue was closed on push. Closing
the rest here; the fixes themselves are already on main.

Fixes #6455. Fixes #6458. Fixes #6466. Fixes #6467. Fixes #6468.
Fixes #6472. Fixes #6465. Fixes #5547. Fixes #5282.

Thanks to xet7.
2026-07-16 17:55:44 +03:00
Lauri Ojansivu 794346e396 cpu-exec qemu-user fallback everywhere; tests for all newest fixes.
Fix #6458 completely: the reporter's host runs inside a hypervisor that
masks AVX, and the old per-tool AVX wrappers (amd64-only, PATH-based)
were bypassed by absolute-path mongod invocations.

- New general snap-src/bin/cpu-exec: runs any binary directly when the
  CPU has the features it declares (--features x86_64=avx,aarch64=atomics
  or WEKAN_REQUIRED_CPU_FEATURES) and through same-arch qemu-user when
  not; plain zero-overhead exec with no declared features. Lookup order:
  WEKAN_QEMU_USER, qemu-<arch> next to the script (bundle/Docker),
  $SNAP/qemu-<arch>, the snap's migratemongo/avx one, then system
  qemu-user(-static). Clear error + direct run when no qemu exists, so
  the real SIGILL surfaces instead of hiding.
- Snap: mongodb-control and migration-control run every mongod 7 through
  cpu-exec, so MongoDB works without AVX (slower) and migration can READ
  modern MongoDB data on such CPUs; completed-FerretDB switch and
  migration fallbacks unchanged (switch_to_completed_ferretdb extracted).
- release-all.yml: every Linux bundle .zip ships cpu-exec + this-arch's
  static qemu-user (amd64/arm64/ppc64le/s390x/riscv64; stripped from
  win64/mac bundles), flowing into Docker and Snap automatically;
  Sandstorm .spk gets both via build-deps.sh. Bundle launcher and Docker
  entrypoint route node/ferretdb through cpu-exec.
- Docs: mongodb-avx-qemu.md and mongodb-raspi4-qemu.md document the
  built-in mechanism.

Tests, with negative cases, for the newest features and fixes at
changelog: htmljs array-vs-attributes (#6459), description draft
normalize/reset (#6455), comment draft lifecycle (#5547), attachment
delete guard (#5282), rules move actions + import normalization (#6472),
snap mongod-failure/migration recovery (#6458 #6466), FerretDB polling
defaults (#6467 #6468), UI density guards for every #6465 fix, and a
behavioral cpu-exec test against fake cpuinfo + fake qemu. All wired
into test:unit:node. Go table tests for the FerretDB SQLite filter
pushdown are in the wekan/FerretDB repo.

Updated ChangeLog.

Thanks to xet7.
2026-07-16 17:40:24 +03:00
Lauri Ojansivu ed19445d30 Fix #6459 #6455 #6458 #6466 #6467 #6468 #6472 #6465 #5547 #5282.
- #6459: card More menu rendered empty (no Delete): the vendored jade
  compiler's htmljs patch made isConstructedObject(Array) false, so
  'label {{_ ...}}:' lines got their content array mis-assigned as tag
  attributes and Blaze threw 'foreign objects in attributes' killing the
  whole cardMorePopup. Also fixed the list More link input (nonexistent
  rootUrl helper -> absoluteUrl).
- #6455: saving a card description never removed a pre-existing unsaved-
  edits record, and the draft/description comparison mismatched on Markdown
  hard-breaks and empty descriptions, re-creating a phantom draft on every
  save. Save now clears the draft; both sides normalized the same way.
- #5547: comment drafts: the draft-saving escape handler had been disarmed
  since 2019 (flag with no setter), so a comment being written was lost
  when the card closed. Drafts are now saved continuously (debounced),
  flushed on teardown, cleared on submit; text no longer visually vanishes
  on outside clicks.
- #5282: attachment delete: guard against the doc being already evicted
  from Minimongo ('Removed nonexistent document'), like the existing
  comment/checklist guards.
- #6472: rules move card to top/bottom: fall back to the card's current
  list when listName does not resolve (was a swallowed TypeError = rule
  silently did nothing); wizard stored listTitle which the engine never
  reads (listName); empty destination wrote sort +-Infinity; rules JSON/CSV
  import now uses the rules.createRule method instead of client inserts
  rejected into minimongo limbo, and defaults missing trigger matching
  fields to '*'; empty boardId no longer overrides the real board; the
  server-side orphaned-cards fallback in List.cards()/cardsUnfiltered()
  now actually resolves its async swimlane lookup.
- #6465: desktop density back to WeKan 6.09: base font 14px and headings
  22/18/16px (vw clamps resolved to 18/24px on desktop), All Boards
  wrapper padding 10px/15px, card details docks to the right edge instead
  of floating on top of its own card (still draggable), 20px canvas
  padding instead of ~48px white borders, removed the force-all-card-text-
  to-title-size rule, admin org/team table Select/Unselect-all header
  links are compact icons and header cells may wrap, zoom pill no longer
  cut off, one-click board settings cog in the board header.
- #6458 #6466: snap: mongodb-control now checks mongod's exit status and
  does an AVX pre-flight (MongoDB 5+ needs AVX; without it mongod SIGILLs)
  instead of pinging a dead port forever behind a 502: it switches to a
  completed FerretDB migration, or (re)runs the migration (AVX-free) with
  a 3-attempt guard. Migration importers no longer fail the whole run on
  >=10 per-item errors ('Avatars Errors' etc.), and a failed 3.x-source
  migration keeps the copied FerretDB data + checkpoint and resumes next
  start instead of deleting it and 'falling back' to a mongod 7 that
  cannot open 3.x data files.
- #6467 #6468: with FerretDB, calm Meteor's no-oplog polling storm
  (METEOR_POLLING_THROTTLE_MS=2000, METEOR_POLLING_INTERVAL_MS=30000
  defaults in the snap and bundle launcher; overridable). The FerretDB
  fork gets filter pushdown, a sane pool size and less locking in its own
  repo.
- Updated ChangeLog.

Thanks to xet7.
2026-07-16 17:08:31 +03:00
Lauri Ojansivu 1193f3a4b5 LDAP: cleaner server-context org/team sync call, fix indentation.
Follow-up to PRs #6469 and #6470:

- The login-time org/team sync now clears the inherited method invocation
  context (DDP._CurrentMethodInvocation.withValue) around a normal
  Meteor.callAsync, instead of invoking the raw method handler with a
  hand-made invocation object. This goes through Meteor's normal method
  dispatch (check() argument audits, EJSON argument cloning) while still
  being the server-to-server path (connection null) that the admin guard
  in setUserOrgsTeamsFromLdap allows.
- Added the ddp package dependency to wekan-ldap for the DDP symbol.
- Fixed indentation in localizeAvatarFromBuffer and restored the comment
  explaining the proceedAfterUpload argument.
- Removed a stray double blank line in the LDAP login handler.
- Updated ChangeLog about the merged LDAP fixes.

Thanks to xet7.
2026-07-16 15:47:52 +03:00
Lauri Ojansivu 98ba5fcc9d Merge pull request #6470 from ChristianMa97/feature-ldap-hardening-and-wildcard-group-filter
LDAP: harden org/team sync during login and add wildcard group allowlists
2026-07-16 15:39:54 +03:00
Lauri Ojansivu b735f23045 Merge pull request #6469 from ChristianMa97/bugfix-ldap-double-escaping
Fix LDAP group filters by removing redundant backslash escaping
2026-07-16 15:38:42 +03:00
Lauri Ojansivu 7491c37feb release-all.yml: also build the Sandstorm .spk and attach it to the release.
Added a build-sandstorm job that runs after the GitHub Release is created,
with the same steps as the standalone sandstorm.yml workflow (which still
exists for testing the .spk build on its own), attaching
wekan-<version>-sandstorm.spk to the Release. Non-blocking, so the
experimental Sandstorm build never fails or delays the release.

Thanks to xet7.
2026-07-16 13:27:32 +03:00
Lauri Ojansivu 503601515a Updated ChangeLog. 2026-07-16 13:18:59 +03:00
Lauri Ojansivu 10d5006d2e Merge pull request #6462 from wekan/dependabot/npm_and_yarn/websocket-driver-0.7.5
Bump websocket-driver from 0.7.4 to 0.7.5
2026-07-16 13:12:18 +03:00
cmartin 2513c782f7 feat(ldap): implement LDAP group allowlist parsing and filtering with wildcards support 2026-07-16 10:17:13 +02:00
cmartin c1cae29544 fix(ldap): keep login successful when optional sync fails and fix avatar localization write path 2026-07-16 10:16:59 +02:00
cmartin 98dae04c2d Remove redundant backslash escaping in LDAP filter construction 2026-07-16 09:53:37 +02:00
dependabot[bot] 4e15057277 Bump websocket-driver from 0.7.4 to 0.7.5
Bumps [websocket-driver](https://github.com/faye/websocket-driver-node) from 0.7.4 to 0.7.5.
- [Changelog](https://github.com/faye/websocket-driver-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-driver-node/compare/0.7.4...0.7.5)

---
updated-dependencies:
- dependency-name: websocket-driver
  dependency-version: 0.7.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-15 22:09:17 +00:00
github-actions[bot] f602b9459f Bump versions for v9.95 2026-07-15 12:58:19 +00:00
Lauri Ojansivu b3a9b90f22 Updated ChangeLog. 2026-07-15 15:57:04 +03:00
Lauri Ojansivu 14e6851f01 release-all.yml: publish the snap to the stable channel too
Both snap jobs (snap-native and snap-launchpad) pushed only to candidate, beta
and edge, and stable had to be released by hand afterwards. Publish to
stable,candidate,beta,edge instead. base: core24 allows stable and candidate.

Thanks to xet7.
2026-07-15 15:57:04 +03:00
Lauri Ojansivu ab1c636e71 Snap: resume an interrupted MongoDB to FerretDB migration instead of redoing it
A big migration can run for 5 hours, so being interrupted by a snap refresh,
`snap stop` or a reboot is normal - but it was treated as a failure, and every
interruption threw away all of the work.

Distinguish interrupted from failed. snapd's SIGTERM killed the importer, bash
resumed at the line after the importer call, read the signal exit code (143) as
"the migration failed", and fail_and_run_mongodb deleted the partial FerretDB
SQLite and set migrate=off. Now a SIGTERM/SIGINT trap (and an importer exit code
>= 128, since snapd signals every process in the service cgroup) keeps the
partial database and the checkpoint, leaves auto-migration on, and hands back to
mongodb-control so WeKan keeps serving from MongoDB until the next start resumes.
Both importers flush their checkpoint on the signal and exit 128+signo.

Checkpoint files, not just collections. Only fully-copied collections were
checkpointed; the file phase started over. Worse, the destination path was picked
with an "append _1, _2, ... while the file exists" loop, so every already-
extracted attachment was written a second time under a new name, orphaning the
first copy - a resumed migration silently needed twice the disk the up-front
space check budgeted for. Files are now checkpointed individually (recorded only
once the bytes are on disk AND the record points at them, and re-verified by size
on resume, so a half-written file is never mistaken for a finished one) and
skipped when resuming. The path is now deterministic: idHex is the record's _id,
so two records never collide and an existing file there can only be this record's
own partial extraction, which is meant to be overwritten.

Discard the checkpoint together with the SQLite it describes. The checkpoint
lives in $SNAP_COMMON, not in the SQLite dir that discard_partial_ferretdb wipes,
so it survived. The next migration then trusted it, skipped every "already
migrated" collection, copied only the rest into the now-empty database, and
reported success - leaving the snap serving a FerretDB missing most of its data.

The MongoDB 3 importer had no checkpoint at all and now resumes collections and
files the same way. The source MongoDB data is never touched on any of these
paths.

Thanks to markusst1982 and xet7.
2026-07-15 15:57:04 +03:00
Lauri Ojansivu 4e1e661d99 Fix WeKan not working after FerretDB migration: resume login used a positional projection
Meteor's accounts-base "resume" login handler projects a single array element
with the positional operator:

  Meteor.users.findOneAsync(
    { 'services.resume.loginTokens.hashedToken': hashedToken },
    { fields: { 'services.resume.loginTokens.$': 1 } })

FerretDB rejects that find with "Executor error during find command :: caused
by :: positional operator '.$' couldn't find a matching element in the array"
(code 51246).

Resume is how every already-logged-in browser re-authenticates on page load and
on every DDP reconnect, so the throw logged users out, the client retried the
connection, and the retry loop pinned the FerretDB CPU - the board looked broken
right after a migration that had just taken hours.

Replace the handler with one that projects the whole loginTokens array and picks
the matching token in JS. Upstream already does exactly this in its own $or
fallback query ("Cannot use ...loginTokens.$ positional operator with $or
query"), so the array is known to be small enough to fetch, and behaviour is
otherwise unchanged - including looking up the hashed token on its own first so
the unhashed token is not sent to the database unless it has to be.

Closes #6457

Thanks to markusst1982 and xet7.
2026-07-15 15:57:04 +03:00
github-actions[bot] a84a88794f Bump versions for v9.94 2026-07-15 06:09:00 +00:00
Lauri Ojansivu a5d206e23d Updated ChangeLog. 2026-07-15 09:07:36 +03:00
Lauri Ojansivu aadd653b15 CHANGELOG: new Upcoming section above v9.93 with the post-9.93 fixes
9.93 is released, so move the two commits made after it into a fresh
"# Upcoming WeKan ® release" section above v9.93, organized like the previous releases:

  Features: database setting authoritative + snap run wekan.database mongodb|ferretdb +
            no-downtime handling of a failed migration.
  Bugs:     migration skips dotted (GridFS/CollectionFS/system) collections that FerretDB
            rejects, and cleans up a partial SQLite from a failed migration.

No entry text changed; only relocated.
2026-07-15 09:01:37 +03:00
Lauri Ojansivu 7d271413bf Snap: make database setting authoritative + wekan.database command + no-downtime on failure
Problem: the control scripts force-switched to FerretDB whenever a *.sqlite file existed, so
`snap set wekan database=mongodb` did not stick and a failed migration caused downtime with no
way to keep WeKan running on MongoDB while fixing it.

- The `database` snap setting is now AUTHORITATIVE. Removed the "FerretDB SQLite present ->
  force ferretdb / disable mongodb" overrides from wekan-control, ferretdb-control and
  mongodb-control. wekan-control keeps only the safety guard (never start an EMPTY FerretDB
  while MongoDB has data).

- New `snap run wekan.database mongodb|ferretdb` command (bin/wekan-database): switch which
  database WeKan runs on. `mongodb` also pauses auto-migration (migrate=off) so it does not
  switch back; `ferretdb` requires a non-empty SQLite first.

- Auto-migration can be paused: migration-pending now also returns "not pending" when
  `snap get migrate` == off. A migration that FAILS now runs fail_and_run_mongodb: delete the
  partial SQLite, set migrate=off, and exec mongodb-control so WeKan keeps working on MongoDB
  (no downtime, no retry loop). fall_back_to_mongodb likewise sets migrate=off instead of the
  done marker. `snap run wekan.migrate` re-enables migrate and retries.

- finish_success now restarts wekan.wekan so it reconnects to FerretDB after a successful
  migration instead of waiting for the now-stopped MongoDB.

Registered the `database` app in snapcraft.yaml and snapcraft-core26.yaml (also added the
previously-missing migrate/maintenance apps to core26).

Thanks to xet7.
2026-07-15 08:58:36 +03:00
Lauri Ojansivu 73ad55f696 Snap migration: skip dotted collections (FerretDB rejects them) + clean partial SQLite
Two bugs surfaced when the MongoDB 7 -> FerretDB migration finally connected (after the
db-eval fix):

1. FerretDB v1 rejects collection names containing a dot:
     invalid key: "attachments.chunks" (key must not contain '.' sign)
   Both importers tried to copy GridFS internals (attachments.chunks/.files, avatars.*,
   cfs_gridfs.*) and CollectionFS cfs.<bucket>.filerecord collections as TEXT, aborting the
   migration. Now the text phase skips ANY dotted collection: none of WeKan's real data
   collections contain a dot, and the dotted ones are GridFS internals (extracted in the file
   phase), CollectionFS filerecords (now turned into bare attachments/avatars records in the
   file phase via replaceOne upsert:true) or system.* collections.

2. The failed migration had already copied some text collections, leaving a NON-EMPTY but
   incomplete files/db/wekan.sqlite. ferretdb-has-data then saw it as "migrated", so
   mongodb-control disabled MongoDB and the snap tried to serve a half-migrated FerretDB. On
   any failure migration-control now runs discard_partial_ferretdb: stop the temp target
   FerretDB and delete the partial SQLite, so the snap cleanly keeps MongoDB and retries. The
   finish_success switch is also gated on it (`rc -eq 0 && finish_success`).

Source MongoDB data is never modified or deleted.

Thanks to xet7.
2026-07-15 08:58:36 +03:00
github-actions[bot] 1a4283e598 Bump versions for v9.93 2026-07-15 04:47:18 +00:00
Lauri Ojansivu 8ec4855d6c Updated ChangeLog. 2026-07-15 07:46:12 +03:00
Lauri Ojansivu 1088f31a0d Updated translations 2026-07-15 07:44:47 +03:00
Lauri Ojansivu c032c24065 CHANGELOG: new Upcoming section above v9.92 with the post-9.92 fixes
9.92 is released, so move everything committed after it into a fresh
"# Upcoming WeKan ® release" section above v9.92, organized by topic like the
previous releases (features, then bugs):

  Features: wekan.migrate command; maintenance mode + maintenance page.
  Bugs: db-eval driver resolution (ESM ignores NODE_PATH); decide FerretDB vs
        MongoDB from a non-empty SQLite database (not the marker); mongod-7
        readiness probe fast-fail.

v9.92 is restored to what it shipped, including its "migration-success marker is
now authoritative" bug entry (which the Upcoming "presence of a FerretDB SQLite
database" entry supersedes). No entry text changed; only relocated.
2026-07-15 07:41:11 +03:00
Lauri Ojansivu 88e692ef43 Snap migration: fast-fail the mongod-7 readiness probe when the temp mongod has died
ready_via_node waits up to 45s for the temporary source mongod to accept connections, to
allow a large MongoDB 6/7 database time to replay its journal (#6454). Add a liveness check:
if the mongod process (SRC_PIDFILE) has already exited - e.g. mongod 7 cannot open old
MongoDB 3.x data - stop waiting immediately and drop to the MongoDB 3.2 reader, instead of
pinging a dead process for the full timeout. Matters across many unattended 6.09 -> 9.x
upgrades. (With --fork, start_mongod usually already returns non-zero when mongod cannot open
the data; this also covers a mongod that started then died mid-probe.)

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu b9a9bf1593 Snap: require a NON-EMPTY FerretDB SQLite before treating the migration as done
For unattended upgrades at scale (6.09/MongoDB 3.2 -> 9.x/FerretDB across many servers), the
"did the migration succeed?" test must not be fooled by a 0-byte stub file.

- New shared helper snap-src/bin/ferretdb-has-data: exit 0 only if a *.sqlite file bigger than
  0 bytes exists in files/db. Used by mongodb-control, ferretdb-control and wekan-control in
  place of the previous "*.sqlite exists" glob, so the DB decision is based on a real,
  non-empty FerretDB database. (Deliberately does NOT require the -wal file, which SQLite may
  checkpoint away when FerretDB is stopped - that would cause false negatives at startup.)

- migration-control finish_success now verifies the non-empty SQLite database exists (and logs
  the WAL/shm sidecars) BEFORE switching the snap to FerretDB. If the importer returns success
  but wrote no data, it does not switch or set the done marker - MongoDB data is kept and the
  migration retries on the next start.

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu 8db071f94c Snap: fix db-eval driver resolution (ESM ignores NODE_PATH) - the root cause
db-eval is the Node helper the snap uses instead of mongosh to check whether MongoDB/FerretDB
is up, elect the replica-set primary, and probe mongod-7 readiness during migration. It did
`import { MongoClient } from 'mongodb'` while the wrapper set NODE_PATH to the bundled driver
-- but Node's ESM loader IGNORES NODE_PATH (only the CommonJS loader honors it). So the import
resolved nothing and db-eval exited BEFORE opening any connection, making every
ping/primary/rs-* check fail.

This single bug explains the whole family of failures seen this week:
  - WeKan looping "MongoDB not ready yet, retrying..." / "FerretDB not ready yet..." forever
    even though the database was running and listening;
  - replica-set initialisation failing;
  - the MongoDB -> FerretDB migration falling back to MongoDB 3.2 because the mongod-7
    readiness probe never connected (mongod 7 opened the data fine, but db-eval couldn't reach
    it), and 3.2 cannot read WiredTiger-7 files -> no FerretDB SQLite produced.

Proof from the migration source mongod log: mongod 7 reached "Waiting for connections" with
ZERO "Connection accepted" before the readiness timeout killed it; the only connection in the
file was the native `mongo` shell to mongod 3.2.

Fix: resolve the driver with createRequire (CommonJS resolution honors NODE_PATH and the
bundle layout), anchored inside the modern bundle (npm-mongo v6 first). Applied the same fix
to the standalone migrate-schema-v843.mjs and migrate-gridfs-to-fs.mjs tools.

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu 0ebf34aedc Snap: add wekan.migrate + wekan.maintenance commands and a maintenance page
wekan.migrate (bin/wekan-force-migrate): force a fresh MongoDB -> FerretDB migration,
ignoring the database setting and the migration marker. Removes any partial FerretDB SQLite +
resume checkpoint, sets database=mongodb, and starts wekan.mongodb so migration-control
re-reads the existing MongoDB (3 or 7) data and migrates again. Source MongoDB data untouched.

wekan.maintenance on|off (bin/wekan-maintenance): toggles a $SNAP_COMMON/.wekan-maintenance
marker. While present, mongodb-control / ferretdb-control do NOT auto-migrate or auto-disable,
so `snap start wekan.mongodb` or `snap start wekan.ferretdb` stays up and you can reach the
data over the MongoDB wire protocol on port 27019 (one at a time - they share the port).

Maintenance page (bin/wekan-maintenance-page.mjs): in maintenance mode wekan-control execs
this instead of WeKan; it serves an "under maintenance" page (HTTP 503) on the web port for
ALL URLs. It shows the Admin Panel product name if set (not "WeKan"), read from
$SNAP_COMMON/.productname.txt, which is cached while a database is running by wekan-control
(new db-eval `product-name` command) and by both migration importers - so it is available even
when both databases are stopped.

Registered `migrate` and `maintenance` apps in snapcraft.yaml.

Thanks to xet7.
2026-07-15 07:32:58 +03:00
Lauri Ojansivu 3f42d4d3f0 Snap: decide FerretDB vs MongoDB from SQLite data, not the migration marker
Regression fix. An earlier change made mongodb-control/ferretdb-control/wekan-control key
the "use FerretDB / disable MongoDB" decision off the .migration-to-ferretdb-done marker.
But migration-control ALSO writes that marker when the migration falls back (data
unreadable, tools missing) or when there is nothing to migrate - producing NO FerretDB
data. So on a server whose MongoDB 7 -> FerretDB migration failed:
  - mongodb-control saw the marker -> "migration already finished; disabling mongodb
    service" -> disabled MongoDB;
  - FerretDB SQLite (files/db) was EMPTY;
  - database=mongodb, so wekan-control waited on MongoDB -> "MongoDB not ready yet,
    retrying in 5 seconds..." forever, and wekan.mongodb refused to stay up.

Now all three scripts decide from ACTUAL DATA ON DISK: MongoDB is disabled / FerretDB is
forced ONLY when a *.sqlite database exists in files/db. An empty files/db means the
migration did not succeed, so mongod starts normally and WeKan keeps working (and the
migration can be retried). The marker still prevents re-running a finished migration.

Thanks to xet7.
2026-07-15 07:32:57 +03:00
github-actions[bot] 3e6a11d7f0 Bump versions for v9.92 2026-07-15 02:51:17 +00:00
Lauri Ojansivu 3bd9da70d4 Updated ChangeLog. 2026-07-15 05:50:09 +03:00
Lauri Ojansivu 8e47eca72e CHANGELOG: note disk-space guard is verified to work in snap confinement
Upcoming section: document that statfs is in snapd's default seccomp allow-list, so the
MongoDB -> FerretDB migration's free-space guard is active on Snap; quotactl is not used and
its absence is covered by the ENOSPC/EDQUOT write-failure fallback.
2026-07-15 05:46:05 +03:00
Lauri Ojansivu 2c895107a0 Snap migration: cite snapd seccomp allow-list in the disk-space guard comments
Document that statfs (and statfs64/fstatfs/fstatfs64/statvfs/fstatvfs) are in snapd's
default seccomp allow-list (snapcore/snapd interfaces/seccomp/template.go), so free-space
checks WORK inside a snap (which also has no per-snap disk quota by default, so statfs shows
real usable space). quotactl is NOT in the allow-list, but it is never called; a snapd
storage-quota group is still caught by the ENOSPC/EDQUOT write-failure path. Only Sandstorm
grains genuinely lack statfs (FUSE unimplemented, quotactl blocked). No behaviour change.
2026-07-15 05:44:32 +03:00
Lauri Ojansivu 22a4f419ce Snap: harden MongoDB 6/7 -> FerretDB importer + never start an empty FerretDB
migrate-mongodb-to-ferretdb.mjs (the modern MongoDB 6/7 driver importer that
migration-control runs, packaged from releases/):
  - Resolve the mongodb driver by probing the WeKan bundle with createRequire anchors and
    USE WHAT WORKS: prefer a v6+ driver that exposes GridFSBucket and speaks OP_MSG, since
    an older bundled v2 driver's OP_QUERY is rejected by FerretDB ("Unsupported OP_QUERY
    command: update"). A plain `import from 'mongodb'` could pick up that v2 driver. Fail
    fast with a clear message if no usable driver is found.
  - Bring it to parity with migrate-mongo3-to-ferretdb.mjs's data-safety and UX: measure
    total attachment/avatar bytes up front and STOP before extracting if they will not fit;
    if space runs out mid-run, stop + delete the partially migrated files + report how much
    more disk is needed (a full disk can corrupt the still-running source MongoDB);
    detect ENOSPC/EDQUOT where free space is not measurable (Sandstorm); show a live
    per-file progress bar; and translate the dashboard into the browser's language.
    Existing checkpoint/resume, schema upgrade, orphan fixing, compaction and index
    creation are unchanged.

wekan-control:
  - On startup, decide the database from what data actually exists on disk (FerretDB SQLite
    at $SNAP_COMMON/files/db, MongoDB WiredTiger at $SNAP_COMMON), not the `database` setting
    alone. If ferretdb is selected but its SQLite is empty while MongoDB still has data,
    revert to database=mongodb instead of starting an EMPTY FerretDB that would look like all
    data was lost, and tell the user to run the migration first.

Thanks to xet7.
2026-07-15 05:35:33 +03:00
Lauri Ojansivu f2a417047b Updated ChangeLog. 2026-07-15 05:30:08 +03:00
Lauri Ojansivu 48e47e6687 Snap: RAM-aware WiredTiger cache so MongoDB starts on < 34 GB RAM boxes (#6454)
Symptom: WeKan never started, looping "MongoDB not ready yet, retrying in 5 seconds..."
forever, even though mongod appeared to be running.

Cause: mongodb-control started mongod with a HARDCODED --wiredTigerCacheSizeGB 32. On any
server with less than ~34 GB RAM, mongod was OOM-killed seconds after startup. That
included the temporary mongod started to initialise the replica set: it died before
db-eval could reach it (log showed "child process started successfully" then 30 s of
failed pings), so the replica set was never initiated. The final mongod then ran with
--replSet rs0 but no config, no PRIMARY was elected, and wekan-control (which needs the
replica-set primary for Meteor change streams) waited forever.

Fixes in mongodb-control:
  - WiredTiger cache is now RAM-aware: ~50% of (MemTotal - 1 GB), the same formula mongod
    uses for its own default, capped at 32 GB, min 1 GB. Override with
    MONGODB_WIREDTIGER_CACHE_GB. Applied to both the temp replica-set-check mongod and the
    final mongod.
  - Temporary-mongod readiness wait raised 30 s -> 90 s so a large database that needs
    longer to replay its journal still gets its replica set initialised/reconfigured.

Fix in migration-control:
  - The temporary source mongod started to READ the data during a MongoDB -> FerretDB
    migration now uses a conservative RAM-aware cache (1/4 of (MemTotal - 1 GB), cap 8 GB,
    override MIGRATION_MONGOD_CACHE_GB), leaving room for the target FerretDB and the Node
    importer that run alongside it. Applied only to the mongod 7 (WiredTiger-only) read so
    the flag can never clash with a legacy MMAPv1 MongoDB 3.x dbpath.

Thanks to xet7.
2026-07-15 05:23:16 +03:00
Lauri Ojansivu 22804fdf38 Snap: make the MongoDB->FerretDB migration-done marker authoritative
Symptom: on an already-migrated install, `sudo snap start --enable wekan.ferretdb`
started FerretDB and then it immediately stopped itself, logging:
  "database is 'mongodb', not 'ferretdb'. Disabling ferretdb service."

Cause: every DB service keyed its behaviour off the `database` snap setting alone. If
that setting was never flipped to ferretdb (e.g. the migration's INTERNAL `snapctl set`
did not re-run the configure hook), ferretdb-control read `database=mongodb` and disabled
itself, so FerretDB could not be started by hand without first running
`snap set wekan database=ferretdb`.

Fix: the marker $SNAP_COMMON/.migration-to-ferretdb-done is now authoritative.
  - ferretdb-control: if the marker exists, repair database=ferretdb and keep running
    instead of self-disabling.
  - mongodb-control: if the marker exists, repair database=ferretdb and disable mongodb,
    BEFORE the migration-pending check so a finished migration is never re-attempted.
  - wekan-control: already forces ferretdb on the marker (previous commit).

Immediate recovery on an already-migrated install running an older release:
  sudo snap set wekan database=ferretdb

Thanks to xet7.
2026-07-15 04:58:44 +03:00
Lauri Ojansivu 87783b6f15 Snap: on startup, start the configured database instead of waiting forever
wekan-control (the wekan.wekan service) only WAITED for the database DATABASE points at
and never started a DB service, so if both wekan.ferretdb and wekan.mongodb were
stopped/disabled WeKan hung on "MongoDB not ready yet, retrying..." forever.

Now, on start, wekan-control:
  1. If the migration-success marker $SNAP_COMMON/.migration-to-ferretdb-done exists,
     forces database=ferretdb, then enables + starts wekan.ferretdb and stops + disables
     wekan.mongodb. This self-heals the case where the migration's INTERNAL `snapctl set`
     never re-ran the configure hook that flips the services.
  2. Before waiting, enables + starts whichever DB service is configured
     (snapctl start --enable = snap enable + snap start), so a merely-stopped DB is
     brought back up instead of WeKan waiting forever.

Uses the snap instance name so parallel installs target their own services.

Thanks to xet7.
2026-07-15 04:49:16 +03:00
Lauri Ojansivu 856743baef Updated ChangeLog. 2026-07-15 04:40:55 +03:00
Lauri Ojansivu 8a958650be CHANGELOG: new Upcoming section above v9.91 with the FerretDB post-migration fix
v9.91 was cut as a release, so move the Snap FerretDB service-flip fix out of it into a
fresh "# Upcoming WeKan ® release" section above v9.91. No entry text changed; only
relocated.
2026-07-15 04:39:29 +03:00
Lauri Ojansivu 3cc9dccb3c Snap: start FerretDB after migration (internal snapctl set skips the configure hook)
After a MongoDB -> FerretDB migration, WeKan hung on "MongoDB not ready yet, retrying..."
because no database was running: wekan.ferretdb stayed disabled and wekan.mongodb stayed
enabled-but-stopped.

Root cause: the wekan.ferretdb service disables itself while database=mongodb, and the
service flip that re-enables it lives in snap/hooks/configure. migration-control switches
the backend with an INTERNAL `snapctl set database=ferretdb`, but an internal snapctl set
does not re-run the configure hook, so the flip never happened.

Fix: finish_success now does the flip itself (mirroring the configure hook's ferretdb
branch): enable+start+restart wekan.ferretdb, then stop+disable wekan.mongodb, using the
snap instance name so parallel installs target their own services. FerretDB is started
before MongoDB is disabled so a DB is up first.

Immediate recovery on an already-migrated install:
  sudo snap start --enable wekan.ferretdb && sudo snap stop --disable wekan.mongodb
2026-07-15 04:38:52 +03:00
github-actions[bot] 6744fca2b3 Bump versions for v9.91 2026-07-15 00:25:43 +00:00
Lauri Ojansivu b9b6a55be6 Updated translations. 2026-07-15 03:24:36 +03:00
Lauri Ojansivu e7ec28b7a6 Updated ChangeLog. 2026-07-15 03:23:21 +03:00
Lauri Ojansivu db41dad70f CHANGELOG: add Upcoming section above v9.90; move post-release entries into it
v9.90 (2026-07-15) was already cut, but later work had been appended to it. Create a new
"# Upcoming WeKan ® release" section above v9.90 and move into it everything committed
after the v9.90 cut:
  - new features: Snap/Sandstorm migration dashboard (per-file progress + disk guard)
  - updates: i18n 10 more languages, native-name fixes, pull English-regression report
  - tests: dev server also frees the MongoDB port
  - bugs: MongoDB 3->FerretDB EJSON + stay-on-page; "Always show all code as plain text"
          reactivity race; i18n browser-language -> file mapping fixes
v9.90 is restored to its cut contents (MimeBleed CRITICAL + the original features/updates/
tests/bugs). No entry text changed; only relocated.
2026-07-15 03:20:51 +03:00
Lauri Ojansivu dc6839aef6 CHANGELOG: record the i18n language-detection fixes and additions
Document the Upcoming i18n work: the ja_JP->ja clobber fix, case/underscore-insensitive
language matching, Chinese subtag fallback + zh alias, the cmn tag typo fix (bugs), and
the 10 new selectable languages, native-name corrections, and the pull-translations
English-regression report (updates).
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 2e39d80cf5 i18n: add 10 distinct language picker entries; fix English names to native
Add languages.js entries for translation files that had none (so they were pulled from
Transifex but never selectable): ca-valencia (Català (Valencià)), en_ID/en_SG/en_TR
(English (Indonesia/Singapore/Turkey)), es_CO (Español (Colombia)), fr-FR (Français
(France)), pt_PT (Português (Portugal)), ru-UA (Русский (Украина)), tk_TM (Türkmençe
(Türkmenistan)), wuu-Hans (吴语(简体)). Skipped km_KH and ru_RU (duplicate the existing
km-KH / ru-RU entries) and zh (already aliased to zh-Hans).

Fix language names that used the English name instead of the native one:
  ace  "Acehnese"            -> "Bahsa Acèh"
  cy   "Welsh"               -> "Cymraeg"
  cy-GB"Welsh (UK)"          -> "Cymraeg (Y Deyrnas Unedig)"
  af_ZA"Afrikaans (South Africa)" -> "Afrikaans (Suid-Afrika)"
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 220d04e50d i18n: robust browser-language -> file matching (case/underscore-insensitive, subtag fallback)
Fix several browser-language -> translation-file mapping gaps:

- isLanguageSupported is now case-insensitive (requested) via a new TAPi18n.resolveTag()
  that maps any input to the canonical languages.js tag case-insensitively AND
  underscore<->hyphen-insensitively (so 'zh-hant'->'zh-Hant', 'JA-JP'->'ja-JP', browser
  'af-ZA'->legacy key 'af_ZA'). loadLanguage/setLanguage/ensureLanguageLoaded resolve
  through it, so the loaded file, the i18next code and the stored current tag all agree.
- Bare base codes with no plain-code file resolve via an alias map: 'zh' -> 'zh-Hans'
  (Chinese has only script/region files), so plain 'zh' gives Simplified, not English.
- Browser detection (client/lib/i18n.js) now strips trailing subtags progressively
  instead of jumping to the first segment: 'zh-Hans-CN' -> 'zh-Hans', 'zh-Hant-TW' ->
  'zh-Hant' (Chrome sends these), rather than -> 'zh' -> English.
- languages.js: fix the 'cmn' entry whose tag was the typo 'cnm' (and code 'cn'), which
  mismatched supportedLngs and broke selecting Mandarin; now tag/code = 'cmn'.

Adds i18n.test.js coverage for resolveTag, case-insensitive isLanguageSupported, the zh
alias, the underscore/hyphen match, and each Japanese variant loading its own file.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 4ab6b8561f i18n: give Japanese ja_JP its own file; report English regressions after pull
Two Transifex languages were writing to the same local file: .tx/config lang_map had
`ja_JP: ja`, which sent Transifex's ja_JP INTO imports/i18n/data/ja.i18n.json — the same
file the real Japanese (`ja`) uses. On `tx pull -a -f`, the emptier/untranslated ja_JP
overwrote ja.i18n.json, reverting Japanese strings to the English source. Map it to its
own file instead: `ja_JP: ja-JP` (-> ja-JP.i18n.json), matching languages.js which already
has three separate Japanese entries (ja -> ja.i18n.json, ja-JP -> ja-JP.i18n.json,
ja-Hira -> ja-HI.i18n.json). No other lang_map target collides. languages.js already loads
each to its own file, so no change needed there.

Also add releases/translations/report-english-regressions.mjs and call it from
pull-translations.sh: after pulling, it lists the language files where a previously
translated string reverted to exactly the English source (i.e. untranslated on Transifex),
so the regression is visible instead of being committed silently.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 97911f3e3e CHANGELOG: split migration dashboard features into "new features"
The MongoDB 3 -> FerretDB migration work had grown into one large bug bullet that
actually contained major features (live per-file progress bar, chunk-by-chunk streaming,
disk-space safety guard + rollback, Snap/Sandstorm/availability detection). Move those to
the "new features" section as their own bullet, and keep the EJSON-resolution fix +
stay-on-page hand-off as the bug entry (with a pointer to the feature bullet).
2026-07-15 03:14:08 +03:00
Lauri Ojansivu c3e36d4a2e Snap mongo3->FerretDB: disk guard keys off free-space availability, not platform
Previously the statfs free-space guards were gated on IS_SNAP. But a Snap container may
also be unable to report free space (statfs fails). Drive the behaviour off whether free
space is actually MEASURABLE instead:

- updateDiskFree: skip statfs only on Sandstorm (where it is known-unreliable — reports the
  host disk, not the grain quota); everywhere else ATTEMPT statfs, and on failure leave
  state.diskFree < 0 (unknown).
- ensureDiskForFile / the up-front total-size pre-check: run only when state.diskFree >= 0
  (measurable); otherwise skip and rely on an ENOSPC/EDQUOT write failure.
- Dashboard already hides the free-space figure and switches to "free space required for
  all files" whenever diskFree < 0, so a Snap-with-no-statfs degrades exactly like
  Sandstorm: try, and stop + delete + report on a write failure.

IS_SNAP is now only used for the platform log line. CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 2676e8d434 Snap mongo3->FerretDB: Snap/Sandstorm-aware disk-space guard
The statfs free-space checks only make sense on the Snap (a real filesystem whose size we
can read). A Sandstorm grain cannot see its free space or quota — its FUSE layer does not
implement statfs and quotactl is blocked (confirmed in the Sandstorm source), and statfs
on the writable /var reports the host disk, not the grain quota. So:

- Detect Snap via $SNAP (IS_SNAP) and Sandstorm via SANDSTORM / SANDSTORM_RAW_MONGO_PATH /
  METEOR_SETTINGS.public.sandstorm (IS_SANDSTORM).
- Run the statfs up-front + mid-run free-space guards ONLY on Snap.
- On Sandstorm (and anything else), just try to migrate and detect "out of space" from an
  actual ENOSPC/EDQUOT write failure (in the streaming writer and in the file-loop catches),
  which triggers the same stop + delete-migrated-files + report rollback (onDiskAbort).
- Dashboard shows the free-space figure only when known (Snap); on Sandstorm it says free
  space is unknown and phrases the requirement as "free space required for all files".

Sandstorm source used only for research (grain cannot query disk/quota); not committed.
CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu e5e9f83650 CHANGELOG: document the "Always show all code as plain text" reactivity fix
Add the missing Upcoming bug entry for the alwaysShowCodeAsText fix (the viewer read a
stale ReactiveVar and never re-rendered, so the toggle did nothing; now set inside the
mentions helper from the same reactive setting it already reads). The MongoDB 3 ->
FerretDB migration progress/disk-guard improvements were already recorded in the mongo3
migration entry.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 96428115f4 Snap mongo3->FerretDB: per-file progress, streaming, and disk-space guard
Migration dashboard + migrator improvements for the MongoDB 3 -> FerretDB path:

- Live per-file progress: the file currently being extracted shows its name, size,
  "file N of TOTAL", a percent progress bar, and whether it is an attachment or an
  avatar. File-type words use WeKan's existing translations in the viewer's browser
  language (Accept-Language), read best-effort from the bundle with an English fallback.

- RAM-safe streaming: each GridFS file is streamed chunk-by-chunk (sorted by n) straight
  to disk via a spawned mongoexport + readline, with natural back-pressure, so a multi-GB
  attachment never lands in memory. The old approach buffered the whole file through
  mongoexport's 512 MB stdout maxBuffer and failed on big files.

- Disk-space guard (a full disk can corrupt the still-running source MongoDB): the
  dashboard shows remaining free space; the migrator measures the total size of all
  attachments+avatars up front and stops immediately if the volume cannot hold them; it
  also stops mid-run if free space drops below a safety margin (default 1 GB,
  MIGRATION_MIN_FREE_BYTES). On such a stop it deletes the partially-migrated files to
  free the space back, reports how many files were migrated before stopping, and shows how
  much more disk space is required to migrate all files.

Cannot run the snap here, so this is reviewed but not executed; needs a snap migration run
to verify. CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu c6a75de076 Snap mongo3->FerretDB: fix "EJSON.parse unavailable" (wrong anchor roots)
The MongoDB 3 migrator resolves WeKan's modern bson (with EJSON) and mongodb v6 by
anchoring require() inside the Meteor bundle. It anchored the sub-paths relative to the
script, but in the snap the script runs from $SNAP/bin/ while the bundle is at
$SNAP/programs/server/... (one level up) — so every anchor resolved
$SNAP/bin/programs/server/... (nonexistent) and fell through to the ancient meteor-spk
base bson 1.x with no EJSON, giving "FATAL: EJSON.parse unavailable" and aborting the
migration.

Build candidate bundle roots from $SNAP (env) and the script's parent dirs, then try the
known modern-bundle sub-paths under each (npm-mongo's nested v6 driver first, so the
ancient v2 that FerretDB rejects with OP_QUERY is never chosen). Node's module resolution
walks up from the '_.cjs' anchor to programs/server/npm/node_modules/bson.

Also: on successful hand-off the dashboard now reloads the SAME page the user was on
(the board URL) instead of forcing All Boards, so a reload during migration keeps you in
place. The dashboard already serves the progress page for every URL. CHANGELOG updated.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu 1a9263326c Fix "Always show all code as plain text" not taking effect (reactivity race)
The viewer (mentions -> markdown) applies alwaysShowCodeAsText via a ReactiveVar
(Markdown.alwaysShowCodeAsText) that only a separate startup Tracker.autorun kept in
sync with the setting. But the mentions helper re-renders whenever the settings doc
changes (it reads getCurrentSetting for renderLinksAsPlainText/stripLinks), and that
re-render often runs BEFORE the bridge autorun updated the ReactiveVar — so the inner
markdown helper read the stale flag and rendered links/markdown normally (clickable),
and since mentions does not depend on that ReactiveVar it never re-rendered again.
Net effect: toggling "Always show all code as plain text" did nothing (links stayed
clickable, code stayed rendered), even after reload (same race on load).

Fix: set Markdown.alwaysShowCodeAsText inside the mentions helper, from the same
reactive getCurrentSetting() it already reads, BEFORE rendering the inner markdown
via Blaze.toHTML. The markdown helper now always reads the up-to-date flag in the same
synchronous render, so the toggle takes effect immediately in every rich-text field
(card titles, descriptions, comments, checklists). The startup autorun stays for any
direct +markdown use outside a viewer.
2026-07-15 03:14:08 +03:00
Lauri Ojansivu d923166a96 rebuild-wekan.sh: free the MongoDB port too when stopping a dev server
kill_meteor_on_port freed the app port (3000) and rspack dev-server port (8080) but not
Meteor's bundled MongoDB on app-port+1 (3001). When the meteor parent is SIGKILLed its
mongo child is often orphaned and keeps holding 3001, so the next `meteor run` failed
with "Unexpected mongo exit code 48 ... port was closed, or was already taken". Also
free app-port+1 (which additionally clears a leftover standalone test mongod on :3001),
include it in the readiness guard/wait, and in the SIGKILL escalation. CHANGELOG noted.
2026-07-15 03:14:08 +03:00
github-actions[bot] 0b8564d8c8 Bump versions for v9.90 2026-07-14 21:10:32 +00:00
Lauri Ojansivu a1e868384e Updated ChangeLog. 2026-07-15 00:09:04 +03:00
Lauri Ojansivu 1483c271ff rebuild-wekan.bat: parity — run :3000 test server from precompiled .build\bundle
Bring the Windows batch runner to parity with rebuild-wekan.sh: the :3000 server for
Node E2E + Playwright now runs from the precompiled .build\bundle instead of
`meteor run`, so no recompile. New :start_bundle_server subroutine (used by both the
parallel and sequential "Run ALL tests" flows):

- Builds .build\bundle once if missing (meteor build .build --directory).
- Resolves Meteor's bundled node via `meteor node -e process.execPath` and the sibling
  dev_bundle mongod; runs the bundle with that node (native-module ABI match).
- Installs the bundle's programs\server npm deps once.
- Starts Meteor's mongod on :3001 (db "meteor", matching what the tests seed), or reuses
  a MongoDB already listening there; stops it on exit only if this run started it.
- Starts `node .build\bundle\main.js` with MONGO_URL/ROOT_URL/PORT/WRITABLE_PATH set in
  the parent scope (child inherits them) then unset so Mocha/E2E do not inherit MONGO_URL.

Mocha still uses `meteor test` (:3100, its own build) — it cannot run from a production
bundle. Intro text and cleanup updated; CHANGELOG notes the parity. Untested on Windows
here (no cmd available); needs one Windows verification run.
2026-07-15 00:03:48 +03:00
Lauri Ojansivu 53c6d20342 CHANGELOG: reorganize Upcoming into features / updates / tests / bugs
Group the Upcoming section under the standard headers: CRITICAL SECURITY ISSUE
(MimeBleed), new features (import/export privacy, always-show-code, render-links),
updates (webhook card description, Sandstorm docs), tests (yargs mocha crash, live
progress, .build/bundle reuse), bugs (Sandstorm migrated-grain Admin Panel, Version
storage engine). Also fold the bundle-server "meteor" vs "wekan" database fix into
the .build/bundle test entry.
2026-07-14 23:50:27 +03:00
Lauri Ojansivu 434e7dd1e2 rebuild-wekan.sh: bundle test server must use the "meteor" DB, not "wekan"
The bundle-based :3000 server pointed at mongodb://127.0.0.1:3001/wekan, but the
Playwright and Node E2E tests seed their users/boards straight into
mongodb://127.0.0.1:3001/meteor (tests/playwright/helpers/db.js,
tests/e2e/list-regressions.js) — the DB name Meteor's built-in mongo used under the
old `meteor run`. Same mongod, different database: the app read an empty DB while the
tests seeded another, so every test needing seeded data failed (only the ~9 per browser
that need no seed passed). Point the bundle server at /meteor so app and tests share
one database again.
2026-07-14 23:15:35 +03:00
Lauri Ojansivu a4505f9553 CHANGELOG: record the test-run readiness-hang fix and .build/bundle gate
Complete the Upcoming test-infrastructure notes: the readiness poll that hung on
Meteor's :3000 proxy (progress froze at [0s]) is now fixed with a curl timeout and a
wall-clock-bounded wait, and "Run ALL tests" checks for .build/bundle specifically and
builds it once if missing. Also note the .sh (bundle) vs .bat (meteor run) split in the
server-start wait item.
2026-07-14 22:48:04 +03:00
Lauri Ojansivu aa568e8087 rebuild-wekan.sh: gate tests on .build/bundle existence, not just .build
Before running ALL tests, check for the .build/bundle directory specifically (the
:3000 test server runs it). If it is missing (never built, or a previous build left
.build without a bundle), build it with `meteor build .build --directory` first.
Previously it only checked the .build parent, so a missing/partial bundle was not
rebuilt here (only caught later by the main.js check).
2026-07-14 22:46:22 +03:00
Lauri Ojansivu 5894b000a2 rebuild-wekan.sh: run :3000 test server from precompiled .build/bundle; live scrolling
"Run ALL tests" started the :3000 server with `meteor run`, which recompiles into
.meteor/local even after you already built .build/bundle. Node E2E + Playwright only
need a live WeKan over HTTP, so start that server from the precompiled bundle instead:

- `node .build/bundle/main.js` with Meteor's bundled node (matching native-module ABI),
  MONGO_URL to Meteor's bundled mongod on :3001 (or reuse a MongoDB already listening
  there). Boots in seconds, no recompile. One-time `programs/server` npm install runs
  if missing. mongod is stopped on exit only if this run started it.
- The server boot log and the npm install now STREAM LIVE (scrolling: tail -n 0 -f /
  tee) so you see exactly what is happening instead of a single frozen summary line.
- Sequential mode runs suites strictly one at a time, each streaming its own output;
  the parallel-only combined refreshing table is now skipped in sequential mode.

Mocha still uses `meteor test` (its own .meteor/local-test build) — its in-process
server-side tests cannot run from a production bundle, so that one build is unavoidable
and copying the bundle would not help it. The script warns the bundle is run as-is, so
rebuild it after source changes. CHANGELOG updated.
2026-07-14 22:43:04 +03:00
Lauri Ojansivu a7374ff8a4 rebuild-wekan.sh: stream each test live in sequential mode
In sequential mode only one suite runs at a time, so there is no reason to hide its
output in a log and show only a counter. Refactor the job runner: extract run_job_body
(the actual suite command) and, in sequential mode, pipe it through tee straight to the
console — you now see every test tick by one-by-one as the reporter prints it, then a
final PASS/FAIL + count line. Parallel mode still runs jobs in the background to logs
(the combined table reads them). tee is on the right of the pipe so the suite's exit
code is captured in STATDIR, not tee's.
2026-07-14 22:31:15 +03:00
Lauri Ojansivu 9db870a087 rebuild-wekan: clarify that the test server "build" is Meteor recompiling
The "Building WeKan" message during Run ALL tests suggested a fresh full build even
though the user had already built WeKan. Reword it: the tests start a live
`meteor run --port 3000`, and Meteor recompiles into .meteor/local on start only for
inputs that changed since the last build (sources / node_modules), then caches it.
No behavior change, message only.
2026-07-14 22:24:15 +03:00
Lauri Ojansivu 88b181c18e rebuild-wekan: fix readiness poll hanging on Meteor's :3000 proxy
The "Run ALL tests" server-readiness wait used `curl http://127.0.0.1:3000/sign-in`
with no timeout. Meteor binds the :3000 proxy early and accepts the TCP connection
while the app is still building, but does not send an HTTP response until the build
finishes. So curl blocked on that first connection for the whole build: the loop
never advanced and the new progress line froze at [0s] with only the log header,
even though the build was progressing in the log.

Give curl --connect-timeout 2 --max-time 4 so each poll returns quickly (not-ready
during the build, ready once /sign-in actually answers). The elapsed seconds and the
newest build step now refresh every few seconds as intended. Bound the wait by
wall-clock time (.sh: 1200s; .bat: ~240 polls) instead of a fixed count, since polls
now take a few seconds each. Also add the timeout to the Playwright-ALL precheck.
2026-07-14 22:10:45 +03:00
Lauri Ojansivu cc2a317236 rebuild-wekan: show live test progress in every path (.sh and .bat)
Audited both runner scripts and closed every place that used to sit silent for
minutes during test runs:

- Server first-build wait (Run ALL tests, both modes): .sh shows elapsed seconds +
  newest build step from the server log in place; .bat shows elapsed seconds every
  ~10s and points at the live build log (echoing arbitrary build lines is unsafe on
  cmd because they contain > < | &).

- Sequential per-job (option 2): .sh prints a live elapsed + pass/fail counter per
  job and a final PASS/FAIL line; .bat now runs each job in its own minimized window
  (reusing the proven parallel start-commands + .done-<key> flags) and polls a live
  pass counter one job at a time, instead of redirecting silently to a log.

- Playwright ALL browsers single menu item (.sh option 10 / run_playwright_parallel):
  streams each browser's list reporter live via tee while still saving the log;
  PIPESTATUS[0] keeps per-browser pass/fail accurate through the pipe.

Single-suite items (Mocha, import, E2E, single Playwright) and the parallel combined
progress table already showed progress and are unchanged. CHANGELOG updated.
2026-07-14 21:35:40 +03:00
Lauri Ojansivu 9f5e459dda CHANGELOG: document live test-run progress display in rebuild-wekan.sh
Record the Upcoming test-infrastructure change: "Run ALL tests" now shows elapsed
time + newest build step during the server build, and a live per-job pass/fail
counter in sequential mode, instead of sitting silent for minutes.
2026-07-14 21:23:07 +03:00
Lauri Ojansivu 02b3bed5ce rebuild-wekan.sh: show live progress during test build and sequential jobs
Running ALL tests (menu options 1/2) was silent for minutes in two places:

- While the :3000 WeKan server did its first rspack build, the readiness wait only
  printed dots. Replace that with an in-place line showing elapsed seconds and the
  newest build step from the server log (=> Compiled Rspack..., => Started MongoDB,
  => Started your app), so the build is visibly progressing.

- In sequential mode each job blocks until it finishes, so nothing showed while a
  job built and ran. Add a live in-place counter per job (elapsed seconds + tests
  passed/failed, from count_pass/count_fail on the job log) and a final PASS/FAIL
  line when it completes.

The parallel-mode combined progress table is unchanged.
2026-07-14 21:22:46 +03:00
Lauri Ojansivu ac69e0e171 CHANGELOG: record the confirmed yargs test-build fix and outcome
Update the "Test infrastructure" Upcoming entry with the confirmed root cause and
resolution: the second-stage boot crash was node_modules/yargs-parser/build/lib/index.js
using the guarded ternary `const require = createRequire ? … : undefined`, fixed by
generalizing the createRequire removal to a line-wise match (run before import.meta.url
expansion) and broadening the scan from node_modules/yargs to yargs and its ESM deps.
Also note the three server test files that were present but missing from the curated
server/lib/tests/index.js loader and are now registered. Verified: 450 passing, 0 failing
(411 before the three files were wired in).
2026-07-14 21:07:43 +03:00
Lauri Ojansivu f4d05feb0a patch-yargs-dirname: also patch yargs-parser; register new server tests
The Mocha (server-side) test bundle crashed at boot with 0 tests run because
yargs and its ESM dependencies are dragged into the Meteor test server bundle,
where Meteor 3 wraps every module in a CommonJS function and their native-ESM
constructs (import.meta, `const __dirname`, `const require = createRequire(...)`)
are illegal and crash the bundle before any test runs.

Two gaps remained in scripts/patch-yargs-dirname.cjs:

- The `const require = createRequire(...)` removal (and its trigger) only matched
  the direct-call form. yargs-parser/build/lib/index.js uses the guarded ternary
  `const require = createRequire ? createRequire(import.meta.url) : undefined;`,
  which slipped through and crashed at boot with "Identifier 'require' has already
  been declared". Generalized the match to line-wise `const require = ...createRequire...;`.

- The scan only walked node_modules/yargs, so yargs-parser (the actual offender)
  was never patched. Broadened the scan to yargs and its ESM dependency packages.

Also register the three server test files that were present but missing from the
curated server/lib/tests/index.js (they never ran): the MimeBleed file-validation
bypass regression, the import/export privacy settings, and the impersonation report
query. Server suite now boots and runs (411 passing).

CHANGELOG Upcoming documents the bug and the two-stage debugging journey.
2026-07-14 21:02:26 +03:00
Lauri Ojansivu c21f3d86ef patch-yargs-dirname: fix createRequire mangling that broke the Mocha test build
The yargs postinstall patch expanded import.meta.url -> require('url').pathToFileURL(...)
BEFORE removing the const require = createRequire(...) call. The paren-naive removal
regex createRequire([^)]*) then stopped at the first ) of the injected require('url'),
replacing only part of the statement and leaving a dangling
  /* Meteor: use wrapper require */.pathToFileURL(__filename).href));
in yargs' esm.mjs. That is invalid JS (a leading .pathToFileURL after a comment), so
Meteor's reify parser threw 'Unexpected token (19:33)' when the test server bundle pulled
yargs in -- meteor test reported 0 tests, even though every repo source file was fine.

Fix: remove the createRequire call BEFORE expanding import.meta.url (so the injected
parens can't break the match), and add a repair rule + trigger so re-running the script
collapses the dangling leftover in already-corrupted node_modules/yargs files back to the
marker. Fresh and already-broken files both end valid.
2026-07-14 20:43:01 +03:00
Lauri Ojansivu 5b697f983b Fix test failures from this session's changes
- models/fileValidation.js: a stray BOM (U+FEFF) inside the looksLikeDangerousMarkup
  regex character class broke the server test build with a Babel 'Unexpected token'
  syntax error, so Mocha ran 0 tests. JS \s already matches U+FEFF, so the explicit
  BOM was redundant; use plain \s*.
- models/import.js: importBoard awaited assertImportEnabled() BEFORE the switch that
  check()s the "board" argument. Meteor's audit-argument-checks tracks checks on the
  async context, so awaiting first made the later check(board) not count, throwing
  'Did not check() all arguments during call to importBoard' -- which broke the WeKan
  JSON import (#21) and Excel import (#25) Playwright tests. Check "board" up front
  (Match.OneOf(Object, Array)) before the await; the per-source switch still does its
  specific check.
- server/publications/activities.js: move the disableActivities gate to AFTER the
  check() calls so the argument-checks audit is satisfied when the toggle is on
  (latent: it would have thrown 'did not check all arguments' with activities off).
2026-07-14 19:00:35 +03:00
Lauri Ojansivu faf7ff6efa Snap: migrate ANY MongoDB (3/7/other) to FerretDB SQLite, then shut down MongoDB (#6454)
Supersede the earlier 'keep MongoDB 7' approach: the WeKan snap now moves EVERY
existing MongoDB database onto FerretDB (SQLite) on first boot after an upgrade,
then shuts MongoDB down so only WeKan (Node.js) + FerretDB (SQLite) run. Text data
goes into SQLite; CollectionFS + Meteor-Files GridFS attachments/avatars are
extracted to files/attachments and files/avatars.

migration-control now routes by how the source can be READ, not by keep-vs-migrate:
- mongod 7 can open it (modern MongoDB 6/7, incl. a DB earlier migrated up from 3.x)
  -> keep that mongod as source, run the modern driver importer
  (migrate-mongodb-to-ferretdb.mjs) into a temp FerretDB SQLite target.
- else mongod 3.2 can open it (old MongoDB 3.x) -> run migrate-mongo3-to-ferretdb.mjs
  (legacy mongoexport CLI).
- else (unreadable/corrupt, or tools missing) -> fall back to a normal MongoDB start
  so the service is never left dead, and retry next boot.

On success: mark done + snapctl set database=ferretdb; mongodb-control then stops and
disables the MongoDB service. This also fixes the reported failure where a healthy
MongoDB 7 database was misdetected as MongoDB 3.x, the 3.x path failed, and — because
mongodb-control exec's this script — mongod never started and the service looped.
Probe readiness lengthened 20s -> 45s; the temp source mongod is tracked by pidfile
and force-stopped so it never leaves the dbpath locked. MongoDB data is never
modified or deleted; the snap only switches to FerretDB once migration succeeds.
2026-07-14 18:09:03 +03:00
Lauri Ojansivu 3a4f3c896e Migration dashboard: use Admin Panel product name instead of WeKan
Both migration dashboards (Snap MongoDB-3 importer and the Sandstorm/general
MongoDB importer) hard-coded 'WeKan'. If the migrated database has a product name
set in Admin Panel (settings.productName), the dashboard now shows that name and
does not mention WeKan; otherwise it defaults to WeKan.

- snap-src/bin/migrate-mongo3-to-ferretdb.mjs: read settings.productName via
  mongoexport early in run() into state.product; title/h1/complete message use it.
  The importer->app hand-off poll no longer keys off the product-dependent title
  string; it checks a stable '<!--migration-dashboard-->' marker instead.
- releases/migrate-mongodb-to-ferretdb.mjs: read settings.productName via the driver
  right after the source connects into state.product; the HTML __PRODUCT__ placeholder
  is substituted in buildHtml(). Header clarified to FerretDB (SQLite).
2026-07-14 18:02:43 +03:00
Lauri Ojansivu 0514a2fa5d Snap: don't misdetect MongoDB 7 as MongoDB 3.x on upgrade (#6454)
The one-time MongoDB 3 -> FerretDB migration check probed whether mongod 7 could
open the existing data and, if that short probe failed for ANY reason (journal
recovery, stale mongod.lock, slow disk, large oplog), assumed the data was
MongoDB 3.x. A healthy MongoDB 7 database that failed the probe was then sent down
the 3.x path, where mongod 3.2 also could not open it, logging 'could not start
migratemongo mongod on the MongoDB 3.x data'. Because mongodb-control exec's the
migration script, mongod never started -- the MongoDB service failed to activate
and looped on every restart.

Fix: only run the 3.x migration when the data is POSITIVELY confirmed as MongoDB
3.x (mongod 3.2 can actually open it). In every other case -- MongoDB 6/7, data the
probe could not read, or missing tools -- fall back to a normal MongoDB 7 start in
the same service activation (set the marker + exec mongodb-control) instead of
exiting with nothing running. Also: lengthen the mongod-7 readiness probe 20s -> 45s
so large databases needing recovery are not misread, and track the temporary probe
mongod by pidfile + force-stop it so it never leaves the dbpath WiredTiger lock held.
The MongoDB data is never modified or deleted.
2026-07-14 17:42:44 +03:00
Lauri Ojansivu eb92574650 Updated ChangeLog. 2026-07-14 17:32:09 +03:00
Lauri Ojansivu 422daa6a0c Update Sandstorm Docs about how to install test version.
Thanks to xet7 !
2026-07-14 17:28:59 +03:00
Lauri Ojansivu 339729fd95 Tests for newest Security/Reports features (positive + negative)
- client/lib/tests/renderLinksPlainText.tests.js: the 'render links as plain text'
  toggle and XSS hardening. Config assertions (stripLinks adds <a> to FORBID_TAGS,
  default does not, KEEP_CONTENT) plus real sanitizeHTML() behavior: <a> stripped to
  its text when stripLinks, kept by default, and javascript: hrefs / event handlers /
  <script> removed regardless.
- client/lib/tests/alwaysShowCodeAsText.tests.js: escapeHtmlSource (exposed on the
  Markdown export) escapes <script>/comments/quotes/ampersand-first so code is shown
  not executed; leaves plain text and empty strings unchanged.
- server/lib/tests/impersonationReportQuery.tests.js: the Impersonation report search
  selector (extracted to models/lib/impersonationReportQuery.js so it is testable
  without re-registering the publication) builds a case-insensitive $or across the
  identity fields, escapes regex metacharacters, and is empty for a blank term.

The MimeBleed fix (fileValidationBypass.security.tests.js), SortBleed
(boards.security.tests.js) and import/export anonymization
(importExportSecurity.tests.js) already carry positive + negative tests.
2026-07-14 17:01:35 +03:00
Lauri Ojansivu f6ba472f67 MimeBleed (GHSA-jhph-whx8-wq6p): file-upload MIME bypass -> stored XSS
WeKan's upload validation (models/fileValidation.js) detects a file's real MIME
type by running the Unix 'file' command. On minimal Docker/Alpine images without
'file', detectMimeFromFile() silently returned undefined and the code fell back to
the CLIENT-supplied fileObj.type. An authenticated board member (WITH_API=true)
could upload HTML+JS while setting fileType: image/png; the spoofed type is not on
the dangerous-MIME deny-list, so the dangerous-content scan was skipped and the file
was stored, yielding stored XSS served under the WeKan origin (CWE-434).

Fix: the client-supplied type can no longer gate the safety scan. When content-based
detection via 'file' is unavailable, fall back to a dependency-free JS content sniff
(looksLikeDangerousMarkup) that inspects the real bytes for HTML/SVG/XML/<script>
signatures and forces the dangerous-content scan regardless of the claimed MIME, so a
spoofed image/png that is really HTML+JS is rejected. The sniff matches only definitive
markup, so genuine binary uploads (real PNG/JPEG/PDF, incl. large ones) are unaffected.
Also log a one-time warning when 'file' is missing (previously silent).

Regression test server/lib/tests/fileValidationBypass.security.tests.js covers the
spoofed dangerous uploads and safe binaries.

CVSS:3.1 8.3 High (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). Thanks to HNUfwj and xet7.
2026-07-14 16:49:48 +03:00
Lauri Ojansivu efbd540b48 Updated translations. 2026-07-14 16:38:39 +03:00
Lauri Ojansivu 592d803924 Notifications feature: drop GDPR-specific wording
Disabling activities/notifications/watch can serve any law or policy, not only
GDPR. Remove GDPR mentions from the code comments and the en.i18n.json toggle
descriptions (and the Admin Panel docs), keeping the neutral #5820 reference.
2026-07-14 14:22:58 +03:00
Lauri Ojansivu 86142f542c Outgoing webhooks: include card description in payload (#5143)
A card description was recorded in the activity log (a-changedDescription, #5482)
but never added to the outgoing-webhook / notification params, so integrations got
an event with no description text.

The Activities.after.insert payload now sets params.description (the card's current
description, only when non-empty) for every card event, and forwards the activity's
oldValue/value alongside the existing timeValue/timeOldValue so a description change
carries the before/after text. New fields are additive and backward-compatible.
2026-07-14 14:20:22 +03:00
Lauri Ojansivu a13ce5ef09 Admin Panel / Reports / Impersonation: paginated impersonation log report
Impersonation events are already recorded in the impersonatedUsers collection
whenever an admin impersonates a user (e.g. to export a private board). Surface
them as a new admin report, newest-first, with the same server-side search +
pagination as the other reports.

- server/publications/impersonationReport.js: impersonationReport publication
  (admin-only, sort createdAt -1, limit/skip, $or search over admin/user/board/
  attachment ids and reason) + getImpersonationReportCount method. Also publishes
  the referenced admin/impersonated user docs (same fields the People panel uses)
  so usernames resolve and the Edit user popup has its data.
- adminReports.jade/.js: new Impersonation side-menu entry, sub-template (Date,
  Admin, Impersonated user, Board, Reason), pagination + search wired through the
  shared reportConfig. Clicking a username opens the same editUser popup as
  Admin Panel / People.
- i18n: impersonationReportTitle, impersonation-admin, impersonation-user, reason.
2026-07-14 14:10:38 +03:00
Lauri Ojansivu 247522484c Tests + docs for Admin Panel / Features Security and Notifications options
- server/lib/tests/importExportSecurity.tests.js: positive and negative unit
  tests for the anonymization helpers (map building, user-doc scrubbing, @mention
  rewriting that ignores unknown handles and email addresses, requestedBy/assignedBy
  identity replacement, and safe no-ops on empty input).
- docs/Features/Admin-Panel/Admin-Panel.md: new Features tab section documenting the
  Performance, Security (rich-text hardening + import/export privacy) and
  Notifications (GDPR) toggles.
2026-07-14 14:03:32 +03:00
Lauri Ojansivu f1cc8daebf Admin Panel / Features / Notifications: GDPR activity/notification controls (#5820)
New Notifications category with three GDPR toggles (all default off), enforced
server-side:

- Disable all activities: activity-feed entries are neither recorded
  (Activities.before.insert returns false to cancel) nor shown (the activities
  publication returns nothing, hiding existing and new entries).
- Disable all notifications: the Activities.after.insert dispatch hook returns
  early, so no watch notifications are sent; activity recording is unaffected.
- Disable watch: the watch() method rejects any watch-level change, and the board
  header watch button is hidden.

A synchronously-readable server cache (models/lib/featureFlags.js, kept current by
a Settings observer) lets the sync before.insert hook read the flags without
awaiting. Adds settings disableActivities, disableNotifications, disableWatch,
published in the public setting, plus global Blaze helpers activitiesDisabled /
watchDisabled for UI hiding.
2026-07-14 14:00:49 +03:00
Lauri Ojansivu 06fdede782 Admin Panel / Features / Security: import/export privacy controls
Six new optional toggles (all default off) governing how boards and user data
cross the WeKan boundary, enforced server-side so they cannot be bypassed from
the client:

- Disable all import / Disable all export: master switches that turn off every
  import/export feature. importBoard and cloneBoard call assertImportEnabled;
  every Exporter build()/buildStream() call assertExportEnabled. The import and
  export menu options are hidden in the UI via global importDisabled/exportDisabled
  Blaze helpers.
- Disable import avatars / Disable export avatars: never carry avatars into/out of
  WeKan. Import is gated at the single localizeAvatarFromBuffer choke point (covers
  WeKan JSON import, LDAP, OIDC/OAuth2 login sync and board-open localization) plus
  Trello inlineMemberAvatars; export skips the base64 avatar embed + avatarUrl in
  both Exporter paths.
- Anonymize import users / Anonymize export users: replace username/fullname/initials
  with counter placeholders (user1, user2, ...), drop avatars, and rewrite @username
  mentions + requestedBy/assignedBy in card/comment text. The placeholder word "user"
  is translated to the current user language. Export covers both build() and the
  streaming buildStream() (which does an id-only pre-scan so mentions streamed before
  the users array resolve to matching labels); import scrubs the incoming board in
  WekanCreator before any placeholder user or card is created.

New shared server helper models/lib/importExportSecurity.js holds the settings
read, guards, anonymization map builder and text rewriter. Adds settings
disableAllImport, disableAllExport, disableImportAvatars, disableExportAvatars,
anonymizeImportUsers, anonymizeExportUsers, published in the public setting.
2026-07-14 13:53:11 +03:00
Lauri Ojansivu 3c98336445 i18n: Finnish translations for Admin Panel / Features / Security strings
Translate the new render-links-as-plain-text-description, always-show-code-as-text
and always-show-code-as-text-description strings to Finnish, matching existing
terminology (tarkistuslistat, klikattava, 'Kun käytössä...', 'Oletuksena pois
käytöstä...').
2026-07-14 13:25:50 +03:00
Lauri Ojansivu daeaa2b9d8 Updated translations. 2026-07-14 13:23:12 +03:00
Lauri Ojansivu 38ce497887 Admin Panel / Features / Security: "Always show all code as plain text"
New optional toggle under Admin Panel / Features / Security. When enabled,
rich text is never rendered as markdown or HTML -- the entire source is shown
as escaped plain text in every rich text field (board and card titles,
descriptions, comments, checklists, etc.), so hidden content is always
revealed: HTML comments (<!-- -->), the target URL inside a markdown link,
JavaScript and any other code. All code is always visible, not clickable, and
not running.

This extends the existing invisiblebleed protection (which already showed raw
source for description-less markdown links) to all content, and complements
the "Render links as plain text" toggle.

Implementation:
- New global setting alwaysShowCodeAsText (models/settings.js), published in
  the public setting publication, default off.
- Admin Panel / Features / Security gains a second materialCheckBox toggle.
- The markdown helper has the raw text, so the decision lives there. Because
  the wekan-markdown package cannot import app code, the setting is bridged via
  a ReactiveVar on the exported Markdown object; the rich text viewer
  (editor.js) keeps it in sync with the setting reactively, and the markdown
  helper reads it and renders the fully HTML-escaped source in a <pre> when on.
  Full escaping (not DOMPurify stripping) ensures tags like <script> are shown
  as text rather than removed. The description-less-link branch now uses the
  same full escaping so it too reveals all source.
2026-07-14 13:17:33 +03:00
Lauri Ojansivu 9787445fbb Updated translations. 2026-07-14 12:56:28 +03:00
Lauri Ojansivu f68c91d102 Admin Panel / Features: optional "Render links as plain text" toggle
New optional security feature under Admin Panel / Features / Security. When
enabled, all links -- both markdown links like [label](url) and raw HTML
<a href> tags -- are always shown as plain, non-clickable text in every rich
text field (board and card titles, descriptions, comments, checklists, etc.),
so a link can never be clicked and cannot present misleading anchor text.

This is a hardening option on top of the existing XSS sanitization (which
already strips javascript:/data: schemes, event-handler attributes and
dangerous tags); it addresses #6453, where a board title rendered as a
clickable link. Default off, so links stay clickable.

Implementation:
- New global setting renderLinksAsPlainText (models/settings.js), published in
  the public setting publication.
- Admin Panel / Features gains a Security category with a materialCheckBox
  toggle that saves the setting immediately.
- The shared DOMPurify sanitizer takes a { stripLinks } option that forbids the
  <a> tag while keeping its inner text (KEEP_CONTENT), so links collapse to
  plain text. The rich text viewer (mentions helper in editor.js) passes
  stripLinks based on the setting, gated reactively so toggling re-renders.
2026-07-14 12:51:44 +03:00
Lauri Ojansivu 620b9be180 Updated translations. 2026-07-14 12:09:03 +03:00
Lauri Ojansivu 0ed35e00db Sandstorm: WeKan Admin Panel always available in migrated grains
On Sandstorm the grain owner (Sandstorm 'configure' permission) maps to a
WeKan admin, which gates the Admin Panel and its Attachments / Sandstorm
cleanup tools. A new grain worked because Users.after.insert derives the role
for the freshly inserted user, but a grain migrated from an older WeKan
already contains the user, so that insert hook never runs. Sandstorm
auto-login uses connection.setUserId() (bypassing accounts-base, so
Accounts.onLogin never fires), and the remaining observeChanges hook on
services.sandstorm only fires when that field actually changes on login. When
the migrated user's stored permissions already equal the grain's current
permissions, the login $set is a no-op with no oplog entry, so isAdmin was
never derived and the owner had no Admin Panel.

Reconcile at grain startup (a migrated grain reboots once migration
completes): grant WeKan admin to every Sandstorm user whose stored
permissions include 'configure'. Promote only, never revoke here (that is the
change hook's job on a real permission change), so a stale/empty stored
permission set can never lock the owner out of the Admin Panel.
2026-07-13 20:44:17 +03:00
Lauri Ojansivu 5a90a1987a Admin Panel / Version: show MongoDB storage engine with per-db credentials
The storage engine was read only from serverStatus, which requires the
cluster-level clusterMonitor role. A per-database WeKan user (readWrite on
the wekan db only, as in docs/Platforms/FOSS/Docker/Meteor3/) is not
authorized to run it, so it was rejected and the field stayed 'unknown'.

Fall back to a $collStats aggregation, which needs only the collStats action
that read/readWrite already grant, and read the real engine (wiredTiger /
inMemory) from storageStats. The MongoDB compatible version and Database
commit were already correct: they come from buildInfo, which needs no
special privileges.
2026-07-13 20:30:16 +03:00
github-actions[bot] f1f6fecf44 Bump versions for v9.89 2026-07-13 16:25:58 +00:00
Lauri Ojansivu 6cb4ae2d64 Updated ChangeLog. 2026-07-13 19:24:47 +03:00
Lauri Ojansivu 3ecb90fc04 package-lock.json: record hasInstallScript for yargs postinstall patch
The postinstall script (scripts/patch-yargs-dirname.cjs) added in 6b409c5da
to fix the Meteor/Mocha test boot crash makes npm mark the root package with
hasInstallScript: true. Sync the lockfile to match package.json.
2026-07-13 19:19:10 +03:00
Lauri Ojansivu dc135f6e7d SortBleed (GHSA-xm8x-c8wg-jhmf): board sort allow rule privilege escalation
A low-privilege (comment-only / read-only) board member could escalate to
board admin and take over a private board via the board 'sort' collection
allow rule. To support drag-to-reorder, a second Boards.allow({ update })
rule returned true for any board member whenever the update touched the
'sort' field. Meteor applies the WHOLE modifier once any allow rule approves
and no deny rule rejects, and does not scope the approving rule to the field
that satisfied it. Because canUpdateBoardSort only checked that 'sort' was
AMONG the modified fields, a restricted member could smuggle arbitrary board
mutations (members, permission, title) into the same $set as 'sort' in one
DDP Boards.update call, making themselves admin, flipping the private board
to public, and evicting the legitimate owner. The last-admin deny rule only
inspected $pull, so a wholesale $set of the members array bypassed it.

Fix: restrict canUpdateBoardSort so the sort-reorder rule approves an update
only when 'sort' is the sole modified field. Defense in depth: the last-admin
deny rule now also rejects a $set rewrite of the members array that would
drop the last active admin. Regression test covers the multi-field smuggling
case. The legitimate All Boards drag-reorder is unaffected: it persists order
per-user in profile.boardSortIndex, not in the board document.

CVSS:3.1 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), CWE-863, CWE-269.
Thanks to 5ud0 / Tarmo Technologies and xet7.
2026-07-13 19:08:29 +03:00
Lauri Ojansivu 5b8e47649e Updated translations. 2026-07-13 18:34:58 +03:00
Lauri Ojansivu fb1df544dd Updated translations. 2026-07-13 18:29:09 +03:00
Lauri Ojansivu f031b885d7 CI: add meteor-spk.yml (build the meteor-spk bundle) and sandstorm-store.yml (publish .spk)
- .github/workflows/meteor-spk.yml: assembles WeKan's modernized meteor-spk (the
  packaging tool + its meteor-spk.deps: Node 24, FerretDB, Mongo 3.x CLIs, modern
  sandstorm-http-bridge) via sandstorm-src/build-deps.sh — reusing sandstorm.yml's
  setup — and zips it as meteor-spk-YYYY-MM-DD_HH_MM_SS.zip, the same kind of
  self-contained bundle sandstorm-io/meteor-spk releases but with WeKan's deps baked in.
  Uploaded as an artifact and to the newest GitHub Release. Needs no keyring (it does
  not pack/sign a .spk).

- .github/workflows/sandstorm-store.yml: publishes an already-built .spk to the
  Sandstorm App Market. Installs Sandstorm + assembles meteor-spk and restores
  SANDSTORM_KEYRING like sandstorm.yml, downloads the newest .spk from the WeKan release
  (built and attached by sandstorm.yml), renames it to wekan-<VERSION>.spk (VERSION from
  the release tag, e.g. 9.88, overridable via inputs), and runs 'spk publish
  wekan-<VERSION>.spk' exactly like releases/release-sandstorm.sh. Does not rebuild the
  .spk.

Thanks to xet7.
2026-07-13 14:53:57 +03:00
Lauri Ojansivu a4c1ce3d41 meteor test: patch ALL yargs files' import.meta, not just esm.mjs
The crash moved from yargs/lib/platform-shims/esm.mjs to yargs/build/lib/utils/
apply-extends.js (import.meta.resolve(config.extends)) — yargs' ESM build has more than
one file using import.meta. Rewrite the workaround to scan the whole node_modules/yargs
tree and rewrite every ESM-only construct (import.meta.url/.resolve/.dirname/.filename,
const require = createRequire(...), and yargs' own const __dirname) to the CommonJS
wrapper equivalents in one pass, so a new yargs file can't reintroduce the boot crash.
yargs is never executed at runtime here, so only valid CommonJS parsing matters.

Thanks to xet7.
2026-07-13 14:46:01 +03:00
Lauri Ojansivu 5b0ad4d334 meteor test: also rewrite yargs esm.mjs import.meta / require for the CommonJS wrapper
Renaming only __dirname was not enough: yargs' esm.mjs also uses import.meta.url (invalid
in Meteor's CommonJS module wrapper -> 'Cannot use import.meta outside a module') and
redeclares require via createRequire(import.meta.url) (collides with the wrapper's
require). reify rewrites the file's import statements but not import.meta. Rewrite all
three to use the wrapper's __filename / __dirname / require, which yargs never actually
exercises here (it is only bundled, never called). Idempotent; also patches a file left
half-rewritten by the previous version of this script.

Thanks to xet7.
2026-07-13 14:39:17 +03:00
Lauri Ojansivu 6b409c5dad meteor test: postinstall patch renames yargs esm.mjs __dirname (fixes Mocha boot crash)
.meteorignore does not apply to node_modules under Meteor 3 / rspack (verified: the
rebuilt Mocha bundle still contained node_modules/yargs), so revert that no-op rule and
fix the actual crash instead.

Root cause (pre-existing, unrelated to app code): the whole yargs package gets pulled
into the server bundle as a transitive devDependency, and its ESM shim
node_modules/yargs/lib/platform-shims/esm.mjs declares a top-level
'const __dirname = fileURLToPath(import.meta.url)'. Meteor wraps that .mjs in a CommonJS
module function that already provides __dirname, so it collides —
'SyntaxError: Identifier __dirname has already been declared' — crashing the server
bundle at boot, which is why Mocha reported 0 tests.

Add scripts/patch-yargs-dirname.cjs, run from a package.json postinstall, which renames
yargs' local __dirname (and its .substring/.lastIndexOf uses) to __dirnameYargs so it can
no longer collide with the wrapper's. Idempotent and best-effort (never fails install);
yargs is not used at runtime here so the rename is inert for the app.

Thanks to xet7.
2026-07-13 14:14:32 +03:00
Lauri Ojansivu 298ffcfd3b meteor test: exclude i18next-sprintf-postprocessor/rollup.config.js so yargs is not bundled
'meteor test' (Mocha) crashed at boot with 'Identifier __dirname has already been
declared'. Root cause is unrelated to app code: node_modules/yargs/lib/platform-shims/
esm.mjs declares a top-level 'const __dirname = fileURLToPath(import.meta.url)', which
collides with the __dirname that Meteor's CommonJS module wrapper already provides. yargs
gets pulled into the bundle only because i18next-sprintf-postprocessor (imported by
imports/i18n/tap.js) ships a stray rollup.config.js that does require('yargs'), and a
devDependency (mongodb) has since installed yargs 18 into node_modules so that require now
resolves. That build config is not part of the package's runtime entry
(index.js -> dist/commonjs), so exclude it from Meteor's bundle via .meteorignore.

Pre-existing (the same boot crash happened before this branch's test work) and independent
of the avatar/import-export feature.

Thanks to xet7.
2026-07-13 09:16:33 +03:00
Lauri Ojansivu cf217784d1 Fix Mocha boot crash (test __dirname) and load the new avatar/import-export server files
Two build/load fixes surfaced by running the full test suite:

- server/imports.js: import the new server-only files so they actually load. In
  mainModule mode a file not imported here never loads, so Accounts.onLogin avatar
  localization (server/avatarLocalizationOnLogin.js) and the imported-user reconciliation
  Meteor methods (server/importedUserReconciliation.js) would silently never register.

- Test __dirname collision: 'meteor test' crashed at boot with 'Identifier __dirname has
  already been declared'. The client test index (client/lib/tests/index.js) loads
  boardTriggersClass.tests.js, which referenced the bare __dirname global; under
  meteor test that makes Meteor inject a  colliding with its CommonJS
  wrapper's, aborting the whole server bundle (the same failure already fixed once in
  dependencies.openapi.tests.js — this was the next file to trip it). Replace __dirname
  with a process.cwd()/PWD walk-up in boardTriggersClass.tests.js, and defensively in the
  node-run tests/rtl.test.js, tests/stickers.test.js and tests/stickers.packs.test.js too.

Thanks to xet7.
2026-07-13 09:16:33 +03:00
Lauri Ojansivu bb7fe88bd5 Tests: unit + negative tests for the avatar/import-export feature; extract pure logic
Split the testable, security- and correctness-critical logic of the new avatars +
original-members feature into pure Node modules (module.exports, no Meteor) and cover them
with plain-Node tests/*.test.cjs (positive AND negative), matching the repo's existing
unit-test style. Wired into the test:unit:node script.

- models/lib/avatarUrlSafety.js  (from server/lib/localizeAvatar.js): isExternalAvatarUrl,
  isBlockedIp (SSRF ranges), decodeDataUri, checkUrlHostSync. Tests assert external vs
  local/relative/non-http URLs, that private/loopback/link-local/CGNAT/metadata/multicast
  IPv4+IPv6 (incl. IPv4-mapped) are blocked while genuine public addresses and near-miss
  ranges are allowed, data: decoding, and that non-http(s) schemes, localhost and
  metadata hosts are rejected.
- models/lib/importedUserReconciliationPlan.js (from server/importedUserReconciliation.js):
  planReconciliation. Tests: matches merge, non-matches deactivate, never match another
  placeholder or self or a username-less placeholder.
- packages/wekan-ldap/server/ldapPhoto.js (from sync.js): getLdapPhotoBuffer. Tests the
  Buffer / array / {type:'Buffer'} / base64 shapes and jpegPhoto>thumbnailPhoto, and that
  missing/empty/short values yield null (never throw).
- models/lib/importedBoardMemberPlan.js (from wekanCreator.js): planImportedBoardMember.
  Tests: unmapped member kept by original id but inactive+non-admin, mapped role preserved,
  importer and id-less members dropped.

The extractions are behaviour-preserving; the app modules now import these helpers.

Thanks to xet7.
2026-07-13 09:16:33 +03:00
Lauri Ojansivu ffc6652be0 Changelog: avatars + original members travel with a board across export/import and identity sources
Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu db90fb9e61 Members: distinguish imported placeholders and inactive members on the avatar + sidebar (Increment 5)
Make it obvious which board members are backed by a real account and which are not,
both on card avatars and in the right sidebar member list.

- userAvatar (js/jade/css): the avatar gets an 'imported-member' class (dashed grey ring
  + amber '?' corner badge) for un-reconciled board-import placeholders
  (authenticationMethod:'imported'), and an 'inactive-member' class (greyscale + dim +
  grey badge) for members deactivated on the board (e.g. a person not in LDAP after
  reconciliation). A placeholder is usually both; a real active account gets neither. The
  tooltip explains the state.
- boards.js membersForSidebar(): active members first (as before) then the inactive /
  imported ones appended, and sidebar.jade uses it, so the member list shows EVERYONE and
  which are active vs not instead of hiding inactive members.
- i18n: imported-member-no-account, inactive-member.

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu 6784726bac Imported users: reconcile placeholders with valid accounts, admin merge (Increment 4)
server/importedUserReconciliation.js turns the inert import placeholders into the right
real members deliberately, after import — never auto-mapped during import.

- reconcileImportedUsers(): for each authenticationMethod:'imported' placeholder, find a
  real account with the same username (not another placeholder) — the valid accounts are
  whatever real WeKan accounts exist, typically provisioned by LDAP/OIDC at login — and
  merge the placeholder into it; if none exists (e.g. the person is not in LDAP) leave the
  placeholder and its board memberships inactive (isActive:false).
- mergeImportedUserInto(placeholderId, targetId): reassign every reference (board.members,
  card.members, card.assignees, activities.userId, cardComments.userId) from the
  placeholder to the target, reusing the target's membership if it already is a member,
  then remove the placeholder.
- Admin-only Meteor methods reconcileImportedUsers and mergeImportedUser(placeholderId,
  targetId) for the automatic sweep and for one-off deliberate mapping.

Because reconciliation matches against existing real accounts (which LDAP populates on
login) it needs no direct LDAP query and is provider-agnostic. Merges reassign by the
preserved original _id, so nothing is lost.

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu 0c4b8f4d93 Board import: preserve original members as inert placeholder users, no mapping (Increment 3)
Importing a WeKan board no longer collapses unmapped members onto the importing user (a
wrong mapping attaches the wrong person and can leak board permissions). Instead each
original member carried in board.users becomes an inert placeholder user:

- createPlaceholderUsers() reuses the ORIGINAL _id, so every card/comment/activity
  member reference resolves to the right person with no remapping, and populates
  this.members as an identity map (id -> id) so _user() keeps the original member.
- Placeholders are created with Users.direct.insertAsync (bypassing the after.insert
  hooks — Sandstorm identity sync and the disableRegistration/invitation-code gate that
  would otherwise reject a non-account user), marked authenticationMethod:'imported',
  loginDisabled:true, isActive:false, and carry NO password or email. Username collisions
  suffix the placeholder's name so an existing account is never renamed/stolen.
- The member's avatar (embedded as base64 by the exporter) is restored into files/avatars
  via the localizer, so the original picture shows and is exportable again (Sandstorm too).
- Board membership adds imported members inactive and non-admin, so they are visible but
  hold no permissions until deliberately reconciled later; the importer stays sole admin.

Also guard the Sandstorm Users.after.insert hook to no-op on users without
services.sandstorm (placeholders), which otherwise threw reading preferredHandle.
Mapping/merging to real accounts and LDAP reconciliation are a later, deliberate step.

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu b98abe7b00 Board export: embed each member's local avatar file as base64 (Increment 2)
Board export already carried the original members' username, fullname and initials (the
field whitelist excludes passwords, emails and services). Now, for each member whose
avatar is a local WeKan file, embed the file itself as base64 (profile.avatarFile +
avatarFileName + avatarFileType) alongside the existing avatarUrl reference, in both the
in-memory build() and the streaming buildStream() paths.

Because this reads a local avatars file (no outbound fetch), it works inside a Sandstorm
grain too: a board that already has many stored avatars can be exported with every
member's picture intact, ready to re-import elsewhere. External, not-yet-localized
avatars are still exported as their URL only (Increment 1 localizes those into files
first, on board open / at login).

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu e0c76099f0 Avatars: import LDAP jpegPhoto and OIDC picture at login, localize on every login
Second set of triggers so external avatars from identity providers become local WeKan
avatars (files/avatars), shown in WeKan and carried by board export/import.

- LDAP (packages/wekan-ldap/server/sync.js): syncUserData now reads the binary
  jpegPhoto / thumbnailPhoto (tolerating Buffer / array / {type:'Buffer',data} / base64
  shapes) and, unless the user already has a locally-stored avatar, stores it as a
  self-contained data: URI in profile.avatarUrl. That displays immediately and needs no
  coupling from this package to app code; the app then materialises it to a real file.

- OIDC (packages/wekan-oidc/oidc_server.js): capture the standard  claim (with
  an OAUTH2_AVATAR_MAP override) into serviceData and set profile.avatarUrl for new users.

- App (server/avatarLocalizationOnLogin.js): Accounts.onLogin localizes the logging-in
  user's external avatar immediately (provider-agnostic), complementing the board-open
  trigger. Fire-and-forget, idempotent, never blocks login. (Inside a Sandstorm grain
  outbound fetch is sandboxed, so a Sandstorm profile picture is a best-effort no-op here
  and needs client-assisted capture — a later step.)

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu b4bfc75947 Avatars: localize board members' external avatars when a board is opened
First trigger for external-avatar localization: the single-board publication
(publishComposite('board')) now fires a best-effort, fire-and-forget
localizeBoardMemberAvatars(boardId). For every member whose profile.avatarUrl points
outside WeKan, it copies the image into WeKan's own files/avatars (via the SSRF-guarded
helper) and repoints avatarUrl at the local copy. Idempotent (already-local avatars are
skipped) and it never blocks or delays the publication.

This is the universal catch-all: no matter how a member's avatar became external
(OAuth2/OIDC picture URL, gravatar, a pasted URL), opening the board makes it a local,
exportable WeKan avatar. (Note: inside a Sandstorm grain outbound fetch is sandboxed, so
Sandstorm profile pictures need the provider-side capture added next; LDAP jpegPhoto is
binary and is imported at login, also next.)

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu dcd5f8fdd5 Avatars: add SSRF-guarded external-avatar localization helper (foundation)
First piece of making external avatars (Sandstorm profile pictures, LDAP jpegPhoto,
OAuth2/OIDC picture claims, gravatar, any URL/data-URI) into local WeKan avatar files
under WRITABLE_PATH/files/avatars, so they display without the identity provider and are
carried by board export/import.

server/lib/localizeAvatar.js:
- isExternalAvatarUrl(url): true unless already a local /cdn/storage/avatars/ (or legacy
  /cfs/files/avatars/) URL.
- localizeAvatarFromBuffer(userId, buffer, {type,name}): write bytes into the Avatars
  FilesCollection (onAfterUpload sets the user's local profile.avatarUrl).
- localizeAvatarFromUrl(userId, url): fetch (or decode a data: URI) then store.
- localizeUserAvatarIfExternal(user): idempotent no-op when already local.

Every network fetch is SSRF-guarded: http/https only; the host is resolved and any
private, loopback, link-local, CGNAT, multicast or cloud-metadata (169.254.169.254)
address is refused; 8s timeout; 5 MB cap; image content-type only. Not wired to any
trigger yet (board-open / export / login flows come next).

Thanks to xet7.
2026-07-13 09:16:32 +03:00
Lauri Ojansivu 7273220780 Updated ChangeLog. 2026-07-13 06:05:29 +03:00
Lauri Ojansivu 0d90011aff Update Sandstorm WeKan info.
Thanks to xet7 !
2026-07-13 06:04:50 +03:00
Lauri Ojansivu 2df6a77072 Updated ChangeLog. 2026-07-13 05:58:50 +03:00
Lauri Ojansivu ade9f16db4 Update Sandstorm WeKan info.
Thanks to xet7 !
2026-07-13 05:57:00 +03:00
Lauri Ojansivu 8cbfe3daee Updated ChangeLog. 2026-07-13 05:47:57 +03:00
Lauri Ojansivu 1bf68f54b2 Update Sandstorm WeKan info.
Thanks to xet7 !
2026-07-13 05:46:24 +03:00
github-actions[bot] 619eda0a9d Bump versions for v9.88 2026-07-13 02:12:35 +00:00
Lauri Ojansivu 3d2ab08294 Updated ChangeLog. 2026-07-13 05:10:47 +03:00
Lauri Ojansivu 6a12d968e0 Updated ChangeLog. 2026-07-13 05:05:53 +03:00
Lauri Ojansivu 9f23e6384b Added docs about new Sandstorm WeKan.
Thanks to xet7 !
2026-07-13 05:03:36 +03:00
Lauri Ojansivu 5d44e0b98b Sandstorm: remove the temporary MongoDB-3->FerretDB debugging diagnostics
Now that the grain boots, logs in, lists/opens boards, uploads/downloads attachments,
and migrates an old MongoDB 3 grain end to end, drop the two temporary diagnostics:

- models/attachments.js: revert the Meteor-Files debug flag to false (it was enabled on
  Sandstorm to trace the upload 'session expired [408]' issue and was spamming the grain
  log with the full FilesCollection dump and every find()).
- server/ferretdbQueryDebug.js: remove the mongodb Collection.find wrapper that logged
  FerretDB-rejected selectors (used to find the $elemMatch 'unknown operator: userId'
  issue, since fixed in wekan/FerretDB).

Thanks to xet7.
2026-07-13 04:11:22 +03:00
Lauri Ojansivu e65e06865a Changelog: Sandstorm/Snap migration modern driver, Meteor-Files GridFS + v1 binary, auto-open All Boards
Thanks to xet7.
2026-07-13 03:57:47 +03:00
Lauri Ojansivu b8bdbcd2d7 Sandstorm/Snap migration: auto-open All Boards after migration; WeKan-themed dashboard
Two UX requests once the migration itself works end to end:

- Auto-open All Boards. Previously the dashboard sat on the grain URL for 60s after
  completion and the admin had to reload to reach WeKan. On success the importer now
  exits after ~5s (long enough for the browser to fetch the done page), and the done
  page runs a JS poller that fetches '/' until WeKan's app shell answers (tolerating the
  brief importer->WeKan port hand-off gap) and then location.replace('/') opens All
  Boards. The spinner keeps spinning through the hand-off so it is clear the grain is
  still working; no meta-refresh on the done page (a refresh mid-hand-off would hit a
  dead port). On failure it still stays up a minute with the errors.

- WeKan colours. Recolour the dashboard from the dark scheme to WeKan's blue/white/grey
  (belize blue #2980b9 headings and spinner, white card on light-grey background, grey
  borders, system font).

Thanks to xet7.
2026-07-13 03:57:11 +03:00
Lauri Ojansivu 3476cd17c7 Sandstorm/Snap migration: extract Meteor-Files GridFS attachments + parse legacy v1 binary
With text collections migrating, the 3 attachments still produced 0 extracted files and
errors like 'parse attachments.chunks: Unexpected Binary Extended JSON format
{"$binary":"iVBOR..."}'. Two causes, both fixed:

1. Legacy Extended JSON v1 binary. mongo 3.x mongoexport writes binary as
   {"$binary":"<b64>","$type":"00"} (v1), but modern bson EJSON.parse only
   accepts v2 {"$binary":{"base64":...,"subType":...}}. Rewrite v1 -> v2 per line
   before parsing, so chunk data (and any binary in text collections) parses.

2. Meteor-Files native GridFS buckets were unhandled. The grain stores attachments in
   attachments.files + attachments.chunks (FilesCollection record in the attachments
   collection), not CollectionFS's cfs_gridfs.*. Reassemble those buckets to disk,
   linking each GridFS file to its record via metadata.fileId/versionName, then repoint
   the migrated record's versions.<v> at the file and DROP versions.<v>.meta.gridFsFileId
   — otherwise WeKan's getFileStrategy keeps choosing the (now-empty) GridFS backend and
   the image 404s. Also exclude <bucket>.files/.chunks (and tempstores) from the text
   migration so binary chunks are no longer run through EJSON as documents.

Thanks to xet7.
2026-07-13 03:28:13 +03:00
Lauri Ojansivu cab231ee3a Sandstorm/Snap migration: use the modern mongodb driver (OP_MSG) for FerretDB inserts
Text collections exported and 'inserted N/N' was logged, but every document actually
failed with 'Unsupported OP_QUERY command: update' — nothing reached FerretDB. Cause:
requireAny('mongodb') resolved the ANCIENT meteor-spk base driver (v2.x, at the deps
root) because the modern driver is not directly under programs/server/npm/node_modules
— Meteor nests it at .../meteor/npm-mongo/node_modules/mongodb (v6.16). The 2.x driver
speaks legacy OP_QUERY, which FerretDB rejects; the 6.x driver speaks OP_MSG (the same
driver the WeKan app uses against FerretDB). Add the npm-mongo path as the first
resolver anchor, and log the resolved driver version.

(Separately, GridFS attachment/avatar extraction still finds 0 files for this grain —
its attachments appear to be in Meteor-Files buckets (attachments.files/.chunks) rather
than the CollectionFS cfs_gridfs.* the importer currently handles; to be addressed once
the modern driver confirms text migration and the real attachment collection counts.)

Thanks to xet7.
2026-07-13 02:56:35 +03:00
Lauri Ojansivu d693ae8d7d Changelog: add individual commit links for the folded migration sub-fixes
Link the mongodb default-import, mongoexport --quiet removal, diagnostic/dashboard, and
createRequire commits inline within their parent migration bullets.

Thanks to xet7.
2026-07-13 02:44:17 +03:00
Lauri Ojansivu 5054dab8b6 Changelog: link the wiredTigerCacheSizeGB integer-value fix commit
Thanks to xet7.
2026-07-13 02:41:38 +03:00
Lauri Ojansivu 9c06bd5772 Changelog: update the migration EJSON bullet to the modern-bundle resolution fix
Thanks to xet7.
2026-07-13 02:41:01 +03:00
Lauri Ojansivu beefe441d7 Sandstorm/Snap migration: resolve bson/mongodb from the modern server bundle, not the old base
The EJSON diagnostic was conclusive: require('bson') returned an object with no EJSON,
mongodb.EJSON was undefined, and mongodb/lib/bson* were MODULE_NOT_FOUND. Cause: the
importer sits at the deps root next to the OLD meteor-spk 0.6.0 base node_modules
(bson 1.x with no EJSON at all, plus an ancient mongodb that has MongoClient but no
EJSON re-export), kept there only for the niscu->3.0 stage — so a bare require resolved
those adjacent copies. WeKan's current bson/mongodb (with EJSON) live under
programs/server/npm/node_modules. Anchor createRequire inside that modern bundle first
(falling back to the deps root), and load both mongodb and bson through it. This also
switches the importer to the modern mongodb driver — the same one the WeKan app uses
against FerretDB.

Thanks to xet7.
2026-07-13 02:36:09 +03:00
Lauri Ojansivu 0e0a8bbb33 Sandstorm/Snap migration: load mongodb/bson via createRequire; resolve EJSON robustly
The mongodb default import exposed MongoClient but NOT EJSON (mongodbPkg.EJSON was
undefined), so the previous 'take EJSON from mongodb' attempt still hit the FATAL
guard. Node 24's ESM default-interop for these CommonJS packages drops the
getter/re-export used for EJSON. Switch to createRequire (CJS require returns the real
module.exports with getters intact and honors NODE_PATH), and resolve EJSON by trying,
in order: mongodb.EJSON, mongodb/lib/bson.js, mongodb/lib/bson, bare bson's .EJSON,
and bare bson itself — falling back with a diagnostic list of what each attempt
returned so a remaining failure names the cause instead of just 'undefined'.

Thanks to xet7.
2026-07-13 02:20:23 +03:00
Lauri Ojansivu 5f40f612f5 Changelog: Sandstorm/Snap migration EJSON-from-mongodb-driver fix
Thanks to xet7.
2026-07-13 02:07:45 +03:00
Lauri Ojansivu b0703befd7 Sandstorm/Snap migration: get EJSON from the mongodb driver, not a bare bson import
With mongoexport connecting, every collection then failed with 'parse <coll>: Cannot
read properties of undefined (reading parse)' — EJSON was undefined. The importer runs
with NODE_PATH=programs/server/node_modules, but the EJSON-bearing bson (7.3.0) lives
under programs/server/npm/node_modules, so a bare 'import bson' did not resolve to it
and EJSON came out undefined. The mongodb driver (which resolves fine — MongoClient
works) bundles bson and re-exports EJSON from lib/bson.js, so take EJSON from the same
default import and drop the separate bson dependency. Add an upfront guard that fails
loudly if EJSON.parse is ever missing instead of spamming one error per collection.

Thanks to xet7.
2026-07-13 02:07:24 +03:00
Lauri Ojansivu 7f42db4670 Changelog: Sandstorm/Snap migration mongoexport IPv4 host fix + live dashboard
Thanks to xet7.
2026-07-13 01:49:45 +03:00
Lauri Ojansivu 6142396ae9 Sandstorm/Snap migration: connect mongoexport to 127.0.0.1 (IPv6 localhost broke it)
The real reason every mongoexport failed (revealed once --quiet was dropped):
'error connecting to db server: no reachable servers'. mongoexport is a Go tool
whose --host defaults to 'localhost', which resolves to ::1 (IPv6) first, but the
migration mongod listens only on --bind_ip 127.0.0.1 (IPv4) — so mongoexport could
never reach it, while the mongo shell (whose host defaults to 127.0.0.1) connected
fine and listed all 42 collections. Pass --host 127.0.0.1 explicitly.

Also fill the dashboard Activity panel from the first moment (mongoexport-ready line,
and one line per collection including empty ones) so progress is visible live instead
of the panel sitting on '(waiting…)' while only red connection errors accumulated.

Thanks to xet7.
2026-07-13 01:49:21 +03:00
Lauri Ojansivu 3076f2317e Sandstorm/Snap migration: drop mongoexport --quiet (it hid the error) + live activity log
The --version probe confirmed the bundled mongoexport runs fine (r3.2.22), yet every
per-collection export exited status=1 with empty stderr AND stdout. The empty stderr
was mongoexport's own --quiet flag suppressing its failure message. Drop --quiet so
the real reason (e.g. 'Failed: <reason>') prints to stderr; mongoexport's normal
progress lines also go to stderr, so stdout stays clean Extended JSON either way.

Also surface progress on the dashboard as the user asked: a rolling Activity log
(last ~15 lines: collections found, per-collection export/insert counts, GridFS file
extraction counts) rendered in a panel and mirrored to the grain log, so it is clear
what the migration is doing rather than only red errors appearing later.

Thanks to xet7.
2026-07-13 01:23:38 +03:00
Lauri Ojansivu 62df991532 Sandstorm/Snap migration: diagnose mongoexport failures + live progress dashboard
Every per-collection mongoexport failed with 'failed: undefined' because the error
only logged (r.stderr || r.error): an empty stderr with a non-zero/killed status
(e.g. a SIGSEGV, where spawnSync reports status=null, signal='SIGSEGV', stderr='')
printed as 'undefined', hiding the real cause. Log a full describe(r) instead
(status, signal, error code, stderr AND stdout snippets), and probe
'mongoexport --version' / '--help' once at startup so the next run shows whether the
bundled mongoexport binary itself is broken (dies on --version too → an ABI/bundling
problem) or only the export invocation is. The mongo shell on the same libs works
(listCollections succeeds), so this narrows it precisely.

Also make the progress dashboard obviously live: a CSS spinner while running (✅/❌
when done), a server 'updated' timestamp that changes on every 2s auto-refresh, and
an error counter — the previous page refreshed but showed no sign of activity.

Thanks to xet7.
2026-07-13 00:58:54 +03:00
Lauri Ojansivu 50abd8f95a Sandstorm/Snap migration: import mongodb as default (CommonJS) under Node 24 too
Same CommonJS/ESM interop issue as the bson import: with { EJSON } fixed, the
importer next threw 'Named export MongoClient not found. The requested module
mongodb is a CommonJS module'. The bundled mongodb driver is CommonJS under Node
24's ESM loader, so import the default and destructure MongoClient. These are the
only two third-party imports; the rest are node: builtins.

Thanks to xet7.
2026-07-13 00:33:32 +03:00
Lauri Ojansivu cafbb1761b Changelog: Sandstorm/Snap migration bson default-import fix for Node 24 ESM
Thanks to xet7.
2026-07-13 00:18:50 +03:00
Lauri Ojansivu a5bcb2e27c Sandstorm/Snap migration: import bson as default (CommonJS) under Node 24 ESM
With the WiredTiger cache fixed, mongod 3.0 now starts and the migration importer
runs, but it crashed immediately: 'import { EJSON } from "bson"' threw
"SyntaxError: Named export 'EJSON' not found. The requested module 'bson' is a
CommonJS module". bson (as bundled in WeKan's server node_modules) is CommonJS, so
Node 24's ESM loader exposes no named exports on it. Import the default and
destructure EJSON from it, as Node's own error message advises. Shared by the Snap
migration too (same Node 24).

Thanks to xet7.
2026-07-13 00:18:33 +03:00
Lauri Ojansivu 0804cd19e8 Sandstorm .spk: mongod 3.0 wiredTigerCacheSizeGB must be an integer (1, not 0.25)
mongod 3.0.7 parses --wiredTigerCacheSizeGB as an integer number of GB, so the
0.25 from the previous commit failed at startup with 'Error parsing option
wiredTigerCacheSizeGB as int: Bad digit "." while parsing 0.25' and the migration
still crash-looped. Use 1 (GB). It is a cache cap, not a preallocation, so a short
migration of a normal-sized grain never actually uses that much.

Thanks to xet7.
2026-07-12 23:57:43 +03:00
Lauri Ojansivu 3b109d14e8 Changelog: Sandstorm MongoDB 3 -> FerretDB migration fix (WiredTiger cache + source DB)
Thanks to xet7.
2026-07-12 23:39:09 +03:00
Lauri Ojansivu 3f5c70e643 Sandstorm .spk: fix MongoDB 3 -> FerretDB migration (WiredTiger cache=0G, wrong source DB)
Importing an existing WeKan grain crash-looped in the one-time MongoDB 3 -> FerretDB
migration: mongod 3.0 forked and its child aborted with exit code 14. Two bugs:

1. WiredTiger cache_size=0G. mongod 3.0 sizes the WiredTiger cache from detected
   RAM (RAM/2 - 1GB), but inside a Sandstorm grain sandbox RAM detection returns 0,
   so it computes cache_size=0G and WiredTiger refuses to open (minimum is 1MB),
   logging 'Value too small for key cache_size' and 'Fatal Assertion 28561' to
   /var/migration-mongod.log before aborting. Pass an explicit
   --wiredTigerCacheSizeGB 0.25 (256MB) to both mongod invocations (the niscu ->
   3.0 stage, which writes new WiredTiger data, and the 3.0 -> FerretDB stage) so
   the cache size never depends on RAM detection. The data itself is fine (turtle
   file: WiredTiger 2.5.3, i.e. written by mongod 3.0; the 'unclean shutdown'
   notice is harmless, WiredTiger recovers from the last checkpoint).

2. Wrong source database. Sandstorm WeKan grains store their data in the
   Meteor-default database 'meteor' (the niscu stage reads/writes 'meteor', and a
   real grain's WiredTiger catalog shows meteor.* collections), but the importer
   was told SRC_DB 'wekan', so even once mongod started it would have exported zero
   collections. Only the FerretDB target database is 'wekan'.

Thanks to xet7.
2026-07-12 23:38:52 +03:00
Lauri Ojansivu 455e498482 Changelog: Sandstorm open All Boards + grain URL sync fix
Thanks to xet7.
2026-07-12 23:12:45 +03:00
Lauri Ojansivu 3ab2b9dccb Sandstorm .spk: open All Boards (not sign-in) and keep the grain URL in sync
Two grain-navigation regressions vs the last working Sandstorm build (v6.15):

1. Opening a grain showed 'Must be logged in' instead of the All Boards page.
   On Sandstorm the platform authenticates the user asynchronously over DDP via
   connection.setUserId(), which (unlike a password login) does not set
   Meteor.loggingIn(), so Meteor.userId() is null for the first moments after the
   grain opens. useraccounts' ensureSignedIn route trigger and renderBoardList()
   both bounced that brief null window to the atSignIn route — a sign-in page that
   does not exist inside a grain. config/router.js now uses a Sandstorm-aware
   ensureSignedInUnlessSandstorm wrapper (a no-op on Sandstorm) and renderBoardList()
   no longer redirects on Sandstorm; the All Boards list renders and reactively
   fills in once the platform login lands.

2. The Sandstorm shell's outer grain URL did not update when switching boards. The
   shell rewrites /grain/<id><path> when the app posts a { setPath } message
   (sandstorm shell/imports/client/grain-client.js). The sync was a bare top-level
   Tracker.autorun that could run before flow-router-extra's reactive path tracking
   was initialised and then never re-run. Restore v6.15's event-driven
   FlowRouter.triggers.enter (fresh entering path, no dependency on init order) and
   additionally keep a watchPathChange() autorun wrapped in Meteor.startup as a
   backup. setTitle now also re-runs on route change.

Thanks to xet7.
2026-07-12 23:12:07 +03:00
Lauri Ojansivu d79f90fdfe Changelog: Sandstorm attachment/avatar download authorization via X-Sandstorm-User-Id
Thanks to xet7.
2026-07-12 22:55:11 +03:00
Lauri Ojansivu 1ca7bd3bb6 Sandstorm .spk: authorize attachment/avatar downloads via X-Sandstorm-User-Id
On Sandstorm the grain download route returned 403 for private boards, so
attachment thumbnails, minicard covers and the slideshow all showed broken
images even though the upload succeeded and the file was on disk. The download
handler (server/routes/universalFileServer.js) authorizes private-board files
via a Meteor login token (Authorization / X-Auth-Token / authToken query /
meteor_login_token cookie), but Sandstorm has none of these: authentication is
via connection.setUserId() and the sandstorm-http-bridge X-Sandstorm-* request
headers, so extractLoginToken() returned null and isAuthorizedForBoard() denied
every request. Sandstorm already gates grain access at the platform level, so any
request reaching WeKan is an authenticated grain user; trust it. Both
isAuthorizedForBoard() and isAuthorizedForAvatar() now short-circuit to allow
when Meteor.settings.public.sandstorm is set and the request carries the
bridge-injected X-Sandstorm-User-Id header. Gating on the setting means a spoofed
header cannot bypass auth on non-Sandstorm deployments.

Thanks to xet7.
2026-07-12 22:54:52 +03:00
Lauri Ojansivu 525cd07eee Changelog: add the Sandstorm DDP attachment-upload fix to Upcoming
Link the fix that switches attachment uploads to DDP transport on Sandstorm, since
the bridge strips Meteor-Files' custom x-* HTTP upload headers.

Thanks to xet7.
2026-07-12 22:29:06 +03:00
Lauri Ojansivu dc8ff6e6ca Sandstorm .spk: upload attachments over DDP (bridge strips Meteor-Files' HTTP headers)
Adding a file to a card in the grain failed with HTTP 400 "Can't continue upload,
session expired [408]" and the file silently disappeared. The Meteor-Files HTTP
upload protocol signals the first chunk with an x-start header and tracks the
session with x-mtok / x-chunkid / x-fileid / x-eof, but Sandstorm's request-header
whitelist (web-session.capnp) does not include those, so the sandstorm-http-bridge
strips them: the server never sees x-start, treats every request as a chunk
continuation, cannot find the per-fileId session and returns 408. Use
transport: 'ddp' on Sandstorm (detected via Meteor.settings.public.sandstorm) so
uploads go over DDP method calls instead of custom HTTP headers; HTTP transport is
kept everywhere else (it avoids the Safari DDP-reconnect stall).

Thanks to xet7.
2026-07-12 22:28:28 +03:00
Lauri Ojansivu c671a7fc8c Sandstorm .spk: temporarily enable Meteor-Files debug logging on Sandstorm
Attachment uploads in the grain fail with HTTP 400 "Can't continue upload, session
expired [408]": the upload start succeeds but the per-fileId session is gone by the
time a chunk arrives (both the in-memory _currentUploads entry and the
__pre_attachments pre-collection doc are missing), which points at the polling
observe (FerretDB has no oplog) firing a spurious removed event and aborting the
upload. The Attachments FilesCollection had debug: false, so nothing about the
upload path is logged. Enable Meteor-Files debug on Sandstorm only (gated on
Meteor.settings.public.sandstorm) so the grain debug log shows the start /
createStream / observer / continueUpload steps and pinpoints where the session is
lost. TEMPORARY: revert to debug: false once the upload is fixed.
2026-07-12 22:09:57 +03:00
Lauri Ojansivu d5092c9b2d Changelog: add the Sandstorm grain-URL sync fix to Upcoming
Link the fix that keeps the Sandstorm shell's grain URL in sync with the in-app
route via a reactive autorun.

Thanks to xet7.
2026-07-12 21:38:07 +03:00
Lauri Ojansivu 68e59ab66f Sandstorm .spk: keep the grain URL in sync with the in-app route
Navigating between boards worked but the Sandstorm shell's grain URL never updated
(it stayed on the grain root), unlike standalone WeKan. The path was synced via a
global FlowRouter.triggers.enter callback, which does not fire reliably on client
navigation in this flow-router-extra / Meteor 3 setup. Sync it from a reactive
Tracker.autorun on FlowRouter.watchPathChange() instead (the same mechanism the
setTitle sync already uses), so every route change posts setPath to the shell and
the grain URL becomes /grain/<id><path>.

Thanks to xet7.
2026-07-12 21:37:37 +03:00
Lauri Ojansivu 11f87c2115 Sandstorm .spk: temporary diagnostic to log the exact FerretDB-rejected query
Opening a board in the grain fails with "MongoServerError: unknown operator: userId"
(BadValue) - FerretDB rejecting a query MongoDB accepts - but the driver stack trace
has no WeKan call site, so the offending selector is unknown. This wraps the raw
mongodb Collection.find() to log the collection name and exact selector whenever a
cursor iteration throws, to pinpoint the query. TEMPORARY: to be removed once the
query is rewritten FerretDB-compatibly.

Thanks to xet7.
2026-07-12 19:57:16 +03:00
Lauri Ojansivu 21644652e2 Changelog: add the Sandstorm sign-in bounce fix to Upcoming
Link the fix that redirects from the sign-in page to the boards list once the
asynchronous Sandstorm auto-login sets Meteor.userId().

Thanks to xet7.
2026-07-12 18:47:30 +03:00
Lauri Ojansivu 6f3174b23d Sandstorm .spk: bounce from sign-in to the boards list once auto-login lands
The Sandstorm login was actually succeeding - Meteor.userId() gets set (confirmed:
Meteor.connection._userId held a valid id, Meteor.loggingIn() false) - but the grain
stayed on the sign-in page. WeKan's home route (renderBoardList) checks
Meteor.userId() once and, because the Sandstorm header login is asynchronous and
uses connection.setUserId() (bypassing accounts-base, so no Accounts.onLogin fires),
finds it still null on first render and redirects to 'atSignIn' - where the user is
stranded even after login completes moments later. Add a Sandstorm-only reactive
autorun that sends the user from 'atSignIn' back to 'home' as soon as
Meteor.userId() is set, so the grain lands on the All Boards page.

Thanks to xet7.
2026-07-12 18:47:01 +03:00
Lauri Ojansivu fb0c7f7f60 Changelog: add the ROOT_URL-per-request Sandstorm login/CORS fix to Upcoming
Link the fix that rewrites ROOT_URL to the grain's real URL so DDP, dynamic
imports and the Sandstorm auto-login stop failing cross-origin.

Thanks to xet7.
2026-07-12 18:15:37 +03:00
Lauri Ojansivu d5f707fd37 Sandstorm .spk: rewrite ROOT_URL per request to the grain URL (fixes login + CORS)
WeKan loaded but stayed on "Must be logged in", and the console showed
"Cross-Origin Request Blocked ... http://127.0.0.1:4000/__meteor__/dynamic-import/fetch".
The launcher sets a fixed ROOT_URL (http://127.0.0.1:4000, the internal bridge
target), but Sandstorm serves each grain at a per-session host (ui-<hash>.<host>)
via sandstorm-http-bridge, so the client sent its DDP connection and dynamic-import
fetches to 127.0.0.1:4000 - cross-origin and unreachable from the browser - and the
header-based login handshake (a DDP method call) never completed. Add a
WebApp.addRuntimeConfigHook that rewrites ROOT_URL to the grain's real base URL
(X-Sandstorm-Base-Path) per request, and re-asserts the SANDSTORM flag, so the
client talks to the same origin it loaded from. DDP, dynamic imports and the
Sandstorm auto-login now work.

Thanks to xet7.
2026-07-12 18:15:15 +03:00
Lauri Ojansivu be8976ed8c Changelog: add the SANDSTORM=1 auto-login fix to Upcoming
Link the fix that sets process.env.SANDSTORM=1 in the launcher so the
Sandstorm header-based auto-login runs and pages stop showing "Must be logged in".

Thanks to xet7.
2026-07-12 17:57:43 +03:00
Lauri Ojansivu b0712e6656 Sandstorm .spk: set SANDSTORM=1 so the header-based auto-login runs
WeKan loads in the grain but every page showed "Must be logged in". The
wekan-accounts-sandstorm client only starts the automatic X-Sandstorm-* header
login when __meteor_runtime_config__.SANDSTORM is set, and the package only sets
that when process.env.SANDSTORM is present - which nothing did (start.js set
MONGO_URL/ROOT_URL/PORT/WRITABLE_PATH but not SANDSTORM, and the pkgdef env didn't
either). The launcher now sets process.env.SANDSTORM = '1' before loading the WeKan
bundle, so the package enables the runtime flag and the client auto-logs-in the
Sandstorm user from the X-Sandstorm-* headers. (App code detects Sandstorm via a
separate Meteor.settings.public.sandstorm flag from the pkgdef METEOR_SETTINGS,
which already worked.)

Thanks to xet7.
2026-07-12 17:57:21 +03:00
Lauri Ojansivu ad46cb6102 Changelog: add the no-auto-board + global-role Sandstorm change to Upcoming
Link the change that stops auto-creating a board on a new grain and maps the
grain's Sandstorm permissions to the user's global WeKan role.

Thanks to xet7.
2026-07-12 17:40:33 +03:00
Lauri Ojansivu 8f11e8ac66 Sandstorm .spk: don't auto-create a board; map grain permissions to global role
WeKan on Sandstorm is multi-board now, so a new grain/user no longer gets a
hard-coded 'sandstorm'/libreboard board auto-created - the user creates their own
boards from the All Boards page. The Users.after.insert hook no longer inserts that
board + Default swimlane. updateUserPermissions previously added the user as a
member of that single board (which would now crash, since the board no longer
exists); it now maps the grain's Sandstorm permissions to the user's GLOBAL WeKan
role instead - 'configure' (grain owner) becomes a WeKan admin, everyone else is a
regular user who can create and manage their own boards. Dropped the now-unused
Boards/Swimlanes imports.

Thanks to xet7.
2026-07-12 17:40:09 +03:00
Lauri Ojansivu bb8233655c Changelog: add the "open on All Boards" Sandstorm change to Upcoming
Link the change that removes the hard-coded single-board redirect so the grain
opens on the All Boards page.

Thanks to xet7.
2026-07-12 17:37:36 +03:00
Lauri Ojansivu b5945cf596 Sandstorm .spk: open the grain on the All Boards page, not a hard-coded board
WeKan on Sandstorm originally opened a single hard-coded board
(/b/sandstorm/libreboard) and redirected "/" to it. It now supports many boards and
that libreboard is no longer the right destination, so redirecting to it is wrong.
Remove the WebApp.handlers.get("/") redirect entirely: "/" now falls through to
WeKan's normal serving, whose client "home" route (config/router.js) renders the
All Boards list - the right landing page for a multi-board grain. This also removes
the server-side redirect that browsers rejected as a Corrupted Content Error
(FlowRouter.path does not resolve on the server + Content-Length mismatch).

Thanks to xet7.
2026-07-12 17:36:16 +03:00
Lauri Ojansivu 6e60644bdb Changelog: add the "/" redirect (corrupted content) fix to Upcoming
Link the fix that builds the Sandstorm board redirect path directly and sends a
body with a matching Content-Length, so the grain's "/" stops failing with a
browser Corrupted Content Error.

Thanks to xet7.
2026-07-12 17:33:21 +03:00
Lauri Ojansivu ccdd1a0846 Sandstorm .spk: fix the "/" redirect (malformed Location + Content-Length mismatch)
The grain's "/" served a broken 301: Location was ".../:6080board" because
FlowRouter.path() does not resolve on the server in Meteor 3.x (it returned the
bare route name "board"), and the response advertised Content-Length: 90 while
sending an empty body, which the browser rejected as a "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT). Build the board path directly (/b/:id/:slug, the
'board' route) and send a real HTML body (meta-refresh + link) with a matching
Content-Length, so the redirect to the hard-coded Sandstorm board works. Together
with the Accept-Encoding strip (keeps Content-Length valid) and the modern bridge.

Thanks to xet7.
2026-07-12 17:32:54 +03:00
Lauri Ojansivu 04737bd76e Changelog: add the modern sandstorm-http-bridge fix to Upcoming
Link the fix that bundles a modern sandstorm-http-bridge (from the Sandstorm
install) so the grain stops serving Corrupted Content on WeKan's "/" redirect.

Thanks to xet7.
2026-07-12 04:28:04 +03:00
Lauri Ojansivu 7f0706e45f Sandstorm .spk: bundle a modern sandstorm-http-bridge to fix "Corrupted Content"
The grain boots, but the page failed with a browser "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT) on WeKan's "/" redirect (0 bytes, ~8.5s, then
failed). The meteor-spk 0.6.0 base bundles an ancient (~2016) sandstorm-http-bridge
that mangles responses - it always advertises Accept-Encoding: gzip to the app and
mishandles redirect/encoding. build-deps.sh now overwrites the bundled
/sandstorm-http-bridge with the modern one from a Sandstorm install
(/opt/sandstorm/latest/bin/sandstorm-http-bridge; override with SANDSTORM_HTTP_BRIDGE).
Sandstorm is amd64-only so one binary covers it. The sandstorm.yml workflow now
installs Sandstorm before assembling the deps so the bridge is available on the CI
runner. Complements the app-side Accept-Encoding strip.

Thanks to xet7.
2026-07-12 04:27:40 +03:00
Lauri Ojansivu ab8da931cf Changelog: add the Sandstorm Accept-Encoding (corrupted content) fix to Upcoming
Link the fix that strips Accept-Encoding in the Sandstorm grain so Meteor serves
uncompressed responses and the browser stops showing a Corrupted Content Error.

Thanks to xet7.
2026-07-12 03:56:02 +03:00
Lauri Ojansivu 013b6b5266 Sandstorm .spk: strip Accept-Encoding so the grain doesn't serve corrupted (gzip) content
The grain now boots, but the page failed to load with a browser "Corrupted Content
Error" (NS_ERROR_NET_CORRUPTED_CONTENT). sandstorm-http-bridge advertises
"Accept-Encoding: gzip" to the app regardless of what the browser actually sent, so
Meteor served gzip/brotli-encoded responses the browser could not decode. Strip
Accept-Encoding in a WebApp.rawHandlers middleware (which runs before Meteor's
static/boilerplate serving) so responses go out uncompressed; bandwidth is a
non-issue behind the local bridge.

Thanks to xet7.
2026-07-12 03:55:37 +03:00
Lauri Ojansivu 0115622a9a Changelog: add the FerretDB boolean-index-option fix to Upcoming
Link the fix that makes the services.sandstorm.id unique index use boolean
options so FerretDB accepts it and the Sandstorm grain boots past index creation.

Thanks to xet7.
2026-07-12 03:39:35 +03:00
Lauri Ojansivu 975316f5cd Sandstorm .spk: use boolean index options so FerretDB accepts the users index
wekan-accounts-sandstorm created the unique index on services.sandstorm.id with
{unique: 1, sparse: 1}. Real MongoDB accepts the truthy 1, but FerretDB (used by
the Sandstorm .spk) is strict and rejects it with "The field 'unique' has value
unique: 1, which is not convertible to bool", crashing the grain at boot during
index creation. Use real booleans (unique: true, sparse: true).

Thanks to xet7.
2026-07-12 03:39:17 +03:00
Lauri Ojansivu 54aad554aa Changelog: add the sandstorm.js stale-globals boot fix to Upcoming
Link the fix that imports the Users/Boards/Swimlanes/Activities collections and
drops the removed-HTTP-package monkey patch, so the Sandstorm grain boots past
the capnp guard.

Thanks to xet7.
2026-07-12 03:27:25 +03:00
Lauri Ojansivu 60e40199c9 Sandstorm .spk: fix server-boot crashes from stale globals (Users, HTTP) in sandstorm.js
Once the capnp load was made non-fatal, the grain reached the rest of sandstorm.js
and hit two latent Meteor-2.x-isms the Meteor 3.x migration missed (this file only
runs on Sandstorm, so it was not exercised): (1) it referenced the Users, Boards,
Swimlanes and Activities collections as implicit globals, but those are now ES
module default exports, so `Users.after.insert` threw "Users is not defined" at
boot; and (2) it monkey-patched `HTTP.methods` from the meteor/http package, which
was removed in the Meteor 3.x migration, so `HTTP` was undefined. Import the
collections (and Accounts) explicitly, and remove the obsolete HTTP.methods patch
(WeKan's REST API no longer uses it - it uses WebApp/JsonRoutes with header auth).

Thanks to xet7.
2026-07-12 03:27:02 +03:00
Lauri Ojansivu ff3f35ac43 Changelog: add the split-snap-build (native amd64/arm64 + non-blocking Launchpad) to Upcoming
Link the release-workflow change that builds amd64/arm64 natively on GitHub and
moves the exotic arches to a non-blocking per-arch Launchpad matrix, so the
release no longer hangs hours on riscv64.

Thanks to xet7.
2026-07-12 03:21:30 +03:00
Lauri Ojansivu 8549a62c85 Snap release: build amd64+arm64 natively on GitHub, exotic arches non-blocking on Launchpad
The single snap job ran `snapcraft remote-build` for all 5 platforms on Launchpad
and blocked until the slowest resolved, so the release hung ~3.5h on riscv64's
Launchpad queue even though amd64+arm64 finished in minutes. Split it in two:
snap-native builds amd64 (ubuntu-24.04) and arm64 (ubuntu-24.04-arm) NATIVELY on
GitHub runners via snapcore/action-build; snap-launchpad builds s390x/ppc64el/
riscv64 on Launchpad in a NON-BLOCKING (continue-on-error), per-arch matrix
(remote-build --build-for <arch>) so a slow or failed exotic arch never delays the
release or the other arches. Each arch publishes to the Snap Store
(candidate,beta,edge) and attaches to the GitHub Release the moment it finishes.
All 5 arches still ship.

Thanks to xet7.
2026-07-12 03:21:03 +03:00
Lauri Ojansivu 8b9202d677 Changelog: add the Sandstorm capnp grain-boot fix to Upcoming
Link the fix that stops capnp.node's Node 24 ABI mismatch from crash-looping
the Sandstorm grain; the grain now boots and core WeKan works via the
sandstorm-http-bridge headers.

Thanks to xet7.
2026-07-12 03:08:53 +03:00
Lauri Ojansivu f4f22bbe24 Sandstorm .spk: don't crash the grain when capnp.node can't load on Node 24
WeKan on Sandstorm bundles Node 24, but capnp.node (node-capnp) is built for an
older Node ABI (NODE_MODULE_VERSION 83 = Node 14), so `Npm.require('capnp')` in
sandstorm.js failed with ERR_DLOPEN_FAILED and crash-looped the whole grain on
boot. Load Cap'n Proto lazily in a try/catch and degrade gracefully: the grain
boots and core WeKan works, because login and user identity come from the
sandstorm-http-bridge X-Sandstorm-* HTTP headers (wekan-accounts-sandstorm) and
need no Cap'n Proto. Only the two capnp-only features are skipped when the addon
cannot load - the Powerbox identity-claim method and the Sandstorm activity
notifications - and they log a clear warning. These can be restored by rebuilding
node-capnp for Node 24, or reimplemented over the bridge's HTTP/JSON API (see the
Sandstorm CHANGELOG: use Cap'n Proto APIs without Cap'n Proto).

Thanks to xet7.
2026-07-12 03:08:23 +03:00
Lauri Ojansivu 32b561cfbf Changelog: add the FerretDB state-dir Sandstorm fix to Upcoming
Link the fix that points FerretDB's --state-dir to writable /var so the
Sandstorm grain starts instead of crash-looping on a read-only /state.json.

Thanks to xet7.
2026-07-12 02:25:07 +03:00
Lauri Ojansivu 1c28b40fd4 Sandstorm .spk: point FerretDB state dir to writable /var so the grain starts
FerretDB persists a state.json (version/UUID) via its state provider even
when telemetry is disabled, and its --state-dir defaults to "." — which in a
Sandstorm grain is "/", read-only. So FerretDB failed with "Failed to create
state provider: failed to persist state: open /state.json: read-only file
system", exited (code 1), and the grain crash-looped. sandstorm-src/start.js
now creates /var/ferretdb and passes --state-dir=/var/ferretdb (plus the
FERRETDB_STATE_DIR env) in startFerret(), covering both the migration and the
steady-state FerretDB launches.

Thanks to xet7.
2026-07-12 02:24:27 +03:00
Lauri Ojansivu 7f5cc03ea9 Changelog: add the Sandstorm .spk and Meteor unit tests fixes to Upcoming
List the glibc-loader grain crash-loop fix, the Sandstorm build-workflow
userns/ref fix, and the Meteor unit tests __dirname server-boot fix in the
Upcoming release section, each linking to its commit.

Thanks to xet7.
2026-07-12 01:58:12 +03:00
Lauri Ojansivu 60116bc72f Meteor unit tests: fix server-boot crash from __dirname in an ESM test file
server/lib/tests/dependencies.openapi.tests.js is an ES module (top-level
import) that referenced the bare __dirname global. Under Node 24 / Meteor 3.5
the compiler injects "const __dirname = fileURLToPath(import.meta.url)" for
ESM files that use __dirname, which collides with the __dirname the CommonJS
module wrapper already provides, so the whole server bundle failed to boot
with "Identifier '__dirname' has already been declared" and the "Meteor unit
tests" CI job died before any test ran. Drop the __dirname seed
(process.env.PWD already reaches the repo root under `meteor test`, and the
suite skips gracefully otherwise).

Thanks to xet7.
2026-07-12 01:57:23 +03:00
Lauri Ojansivu 66d8706c4d Sandstorm build workflow: enable unprivileged user namespaces and build the dispatched branch
Ubuntu 24.04 defaults kernel.apparmor_restrict_unprivileged_userns=1, which
blocks the unprivileged user namespaces the Sandstorm install and the spk
supervisor rely on, so sandstorm.yml now relaxes it on the runner. Also added
a "ref" workflow_dispatch input and check out that ref instead of a hardcoded
main, so a fix branch can be built and tested before it is merged to main.

Thanks to xet7.
2026-07-12 01:57:23 +03:00
Lauri Ojansivu 143607ef22 Sandstorm .spk: bundle the matching glibc dynamic loader so grains start
The .spk bundled the host's new glibc libc.so.6 (Ubuntu 24.04, glibc 2.39)
but kept the old glibc 2.31 ld-linux from the meteor-spk 0.6.0 base, so node
failed at startup with "libc.so.6: undefined symbol: _dl_audit_symbind_alt,
version GLIBC_PRIVATE" (HTTP-BRIDGE exit 127) and the grain crash-looped.
sandstorm-src/build-deps.sh now also copies the host's ld-linux-x86-64.so.2
into the deps so the loader and libc are the same glibc, and adds a [verify]
gate that fails the build if the bundled libc and loader glibc versions
differ or the bundled node cannot run under them - turning a silent grain
crash-loop into a loud build failure.

Thanks to xet7.
2026-07-12 01:57:23 +03:00
Lauri Ojansivu 0f52b6a787 Merge pull request #6447 from wekan/dependabot/npm_and_yarn/mongodb-7.5.0
Bump mongodb from 7.3.0 to 7.5.0
2026-07-12 00:23:37 +03:00
Lauri Ojansivu 87f4536e9d Bump @aws-sdk/client-s3 to ^3.1085.0 to match @aws-sdk/lib-storage 3.1085.0
Dependabot #6451 bumped lib-storage to ~3.1085.0, whose peer requires
client-s3 ^3.1085.0, but client-s3 was left at ^3.1071.0 — breaking
meteor npm install / npm ci with an ERESOLVE peer conflict.
2026-07-12 00:21:55 +03:00
Lauri Ojansivu cdde52392b sandstorm: trim .spk toward Cloudflare's 100 MB upload limit
- Drop the ~300 MB of modern MongoDB Database Tools (wekan/mongo-tools) from the
  Sandstorm build: they are unused there (Sandstorm backs up/restores grains
  itself; the MongoDB 3 -> FerretDB migration uses the legacy migratemongo CLIs +
  the .mjs importer). They stay in the .zip bundle / Docker / Snap, which use them.
- Set PUPPETEER_SKIP_DOWNLOAD=1 for the Meteor build (puppeteer is a test-only
  devDependency; its ~150 MB Chromium must never end up in the .spk).
- Pack step prints the .spk size and warns when it exceeds 100 MB.
2026-07-12 00:02:07 +03:00
dependabot[bot] 62f93fa46c Bump mongodb from 7.3.0 to 7.5.0
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.3.0 to 7.5.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.3.0...v7.5.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:47:29 +00:00
Lauri Ojansivu 5b3a3fb744 Merge pull request #6451 from wekan/dependabot/npm_and_yarn/aws-sdk/lib-storage-3.1085.0
Bump @aws-sdk/lib-storage from 3.1073.0 to 3.1085.0
2026-07-11 23:39:47 +03:00
Lauri Ojansivu 349f4aa046 Merge pull request #6452 from wekan/dependabot/npm_and_yarn/i18next-26.3.6
Bump i18next from 26.3.4 to 26.3.6
2026-07-11 23:39:10 +03:00
Lauri Ojansivu ae1f37658c Merge pull request #6449 from wekan/dependabot/npm_and_yarn/puppeteer-25.3.0
Bump puppeteer from 25.1.0 to 25.3.0
2026-07-11 23:38:24 +03:00
Lauri Ojansivu df94214798 Merge pull request #6448 from wekan/dependabot/npm_and_yarn/tests/playwright/mongodb-7.5.0
Bump mongodb from 7.4.0 to 7.5.0 in /tests/playwright
2026-07-11 23:37:56 +03:00
Lauri Ojansivu 6c22e43be8 Merge pull request #6450 from wekan/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.63.0
Bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.63.0
2026-07-11 23:37:29 +03:00
dependabot[bot] 5e5baf5ed7 Bump i18next from 26.3.4 to 26.3.6
---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:34:11 +00:00
dependabot[bot] 713c13831b Bump @aws-sdk/lib-storage from 3.1073.0 to 3.1085.0
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.1073.0 to 3.1085.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1085.0/lib/lib-storage)

---
updated-dependencies:
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1085.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:33:55 +00:00
dependabot[bot] e400ecf548 Bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.63.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.61.1 to 8.63.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.63.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:33:27 +00:00
dependabot[bot] e9db3a8c2f Bump puppeteer from 25.1.0 to 25.3.0
Bumps [puppeteer](https://github.com/puppeteer/puppeteer) from 25.1.0 to 25.3.0.
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-v25.1.0...puppeteer-v25.3.0)

---
updated-dependencies:
- dependency-name: puppeteer
  dependency-version: 25.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:32:58 +00:00
dependabot[bot] 260a2afacc Bump mongodb from 7.4.0 to 7.5.0 in /tests/playwright
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.4.0 to 7.5.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.4.0...v7.5.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 20:32:45 +00:00
Lauri Ojansivu a8cf44cfaa sandstorm.yml: write signing keyring to ~/.sandstorm-keyring (path meteor-spk reads)
meteor-spk pack crashed with 'open(/home/runner/.sandstorm-keyring): No such file
or directory' because the keyring restore step wrote the decoded SANDSTORM_KEYRING
secret to ~/.sandstorm/sandstorm-keyring, but spk reads the app private key from
~/.sandstorm-keyring (a file directly in $HOME). Write it to the correct path, and
fail early with an actionable message when the secret is missing (the .spk cannot
be signed without it) instead of the cryptic later crash.
2026-07-11 23:12:37 +03:00
Lauri Ojansivu d53e7cafbb sandstorm build-deps.sh: fix 'not writing through dangling symlink' at step 3/7
The meteor-spk 0.6.0 base ships some runtime libs in meteor-spk.deps/lib as
DANGLING symlinks (e.g. libstdc++.so.6 -> a lib that no longer exists). When step
[3/7] refreshed them with the host's newer libs, 'cp -fL' refused with 'not writing
through dangling symlink' and failed the Sandstorm build. Add --remove-destination
so cp deletes the existing destination (dangling symlink included) before copying
the host's real library. Verified: plain 'cp -fL' reproduces the error; with
--remove-destination it succeeds and writes a real file.
2026-07-11 22:59:28 +03:00
github-actions[bot] f278d9ae68 Bump versions for v9.87 2026-07-11 19:44:03 +00:00
Lauri Ojansivu a700152e58 Updated ChangeLog. 2026-07-11 22:42:47 +03:00
Lauri Ojansivu b5f9097681 Updated translations. 2026-07-11 22:42:29 +03:00
Lauri Ojansivu b227a93193 Fix #6446: let board members (not only admins) move cards between swimlanes
In the swimlanes view, boardBody.js disabled the .js-swimlanes sortable — which
also carries moving a card from one swimlane to another — with !isBoardAdmin(),
though its own comment said it should be disabled only for non-members. So a normal
board member could move a card within a swimlane but not between swimlanes (and
could not reorder swimlanes). Gate it on !Utils.canModifyCard() instead (the client
write-access predicate: board member, not comment-only/worker/read-only), matching
the server's allowIsBoardMemberWithWriteAccess.
2026-07-11 22:38:18 +03:00
Lauri Ojansivu 52c62021a9 Updated translations 2026-07-11 22:10:54 +03:00
Lauri Ojansivu 692a258758 Updated ChangeLog. 2026-07-11 22:08:40 +03:00
Lauri Ojansivu 8847798f3c Release: update official + unofficial Node.js download URL paths on wekan.fi install page
The install page (../w/wekan.fi/install/index.html) links to Node.js downloads for
each snap/bundle arch: the OFFICIAL nodejs.org build (amd64/arm64/s390x/ppc64le)
and the UNOFFICIAL unofficial-builds.nodejs.org build (riscv64, which nodejs.org
has no binary for). release-website.sh only updated the displayed Node.js version
number, and version.sh's local website block did not touch Node.js at all, so those
download URLs went stale (404) after a Node.js bump.

Both now rewrite the version in the nodejs.org/dist/vX.Y.Z and
unofficial-builds.nodejs.org/download/release/vX.Y.Z paths and the
node-vX.Y.Z-linux-<arch> filename to the current Dockerfile NODE_VERSION.
2026-07-11 22:08:40 +03:00
github-actions[bot] 087d9fd0d8 Bump versions for v9.86 2026-07-11 17:54:20 +00:00
Lauri Ojansivu da55f1c413 Updated ChangeLog. 2026-07-11 20:53:16 +03:00
Lauri Ojansivu 28b4c6687c gitignore: also ignore locally-cloned wekan/mongo-tools and wekan/mongosh
FerretDB/ and sandstorm/ were already ignored; add /mongo-tools/ and /mongosh/ so
that temporarily cloning those wekan repos under this repo for editing never risks
committing them (they are separate git repos).
2026-07-11 20:23:17 +03:00
Lauri Ojansivu 10db1c3e76 Snap: automated release publishes to candidate + beta + edge (core24), not stable
core24 allows the candidate channel (core26 does not), so the automated
release-all.yml snap job publishes to candidate + beta + edge. The stable channel
is not published by the workflow — it is done manually later once proven. Updated
the workflow, snapcraft.yaml header, CHANGELOG and CPU-platforms.md.
2026-07-11 20:18:19 +03:00
Lauri Ojansivu 1a376b5dde Snap: automated release publishes to edge + beta (stable is manual, core24)
The default snapcraft.yaml is base: core24 (grade: stable) so it CAN be published
to the Snap stable channel, but that is done manually once it is proven stable
enough (core26 cannot go to stable). Revert the automated release-all.yml snap job
to publish to edge + beta channels (was candidate + edge). Update the workflow
header, snapcraft.yaml header, CHANGELOG and CPU-platforms.md to match.
2026-07-11 20:15:57 +03:00
Lauri Ojansivu e20dd132c1 Bundle-centric snap; self-contained bundle launchers restart-loop FerretDB + WeKan
Bundle launchers (releases/ferretdb/start-wekan.sh/.bat): run the bundle's own
per-platform binaries — FerretDB (./ferretdb[.exe]) in the background and WeKan
(./main.js on the bundled ./node[.exe]) in the foreground — inside a restart loop,
so if WeKan exits the whole stack restarts. Ctrl-C / closing the window stops both.

Snap wekan part: the wekan-<arch>.zip bundle is now the single source of every
binary. Handle all snap arches (amd64, arm64, s390x, ppc64el->ppc64le, riscv64);
use the bundle's OWN arch-correct Node.js (fixes riscv64, which nodejs.org has no
build for) instead of re-downloading from nodejs.org; move the bundle's per-arch
MongoDB Database Tools into $SNAP/bin; keep the bundle's ferretdb. Drop the
separate mongotools part (the tools already ship in the bundle), so nothing is
downloaded twice.
2026-07-11 20:13:00 +03:00
Lauri Ojansivu 64da38b412 Embed MongoDB Database Tools (wekan/mongo-tools) in all WeKan builds
Previously the per-arch MongoDB Database Tools were only in the Snap and the
Windows bundle. Now every build embeds them from the wekan/mongo-tools fork's
newest release (per-arch <tool>-<arch>):

- Linux .zip bundles (amd64/arm64/s390x/ppc64le/riscv64) download their arch's 8
  tools; the win64 and macOS jobs drop the inherited Linux tools and add their own
  (win64 .exe, mac-arm64). The Docker image inherits them from the Linux bundle.
- Sandstorm build-deps.sh adds the amd64 tools (separate from the legacy MongoDB
  3.2 migratemongo CLIs used for the one-time migration).

All from wekan/mongo-tools, none from mongodb.com.
2026-07-11 20:02:57 +03:00
Lauri Ojansivu 2a6bdadc15 Drop mongosh, use bundled Node.js + mongodb driver; FerretDB/mongo-tools per-arch; snap FerretDB-only arches
Databases / MongoDB clients:
- Remove the mongosh binary everywhere (snap + bundles). All scripted DB access
  (readiness ping, replica-set initiate/status, v8.43 schema migration) now runs
  through the new snap-src/bin/db-eval (bundled Node.js 24 + the mongodb driver).
  Ports wekan-control, mongodb-control, migration-control, start-wekan.sh/.bat and
  migrate-schema-v843.js -> .mjs off mongosh. The legacy MongoDB 3.2 mongo shell
  (migratemongo, amd64) stays for migration-time reads only.
- MongoDB Database Tools now come from the wekan/mongo-tools fork (pure Go, every
  arch) instead of mongodb.com, in snapcraft and the Windows bundle. version.sh /
  test-download-urls.sh no longer probe mongodb.com for mongosh/tools. The MongoDB 7
  server (mongod) is still fetched from MongoDB (amd64/arm64 only).
- FerretDB v1 downloaded per-arch (ferretdb-<arch>) from wekan/FerretDB, no
  ferretdb.zip: release-all.yml bundle jobs, docker-compose.yml, sandstorm build.

Snap:
- Default snapcraft.yaml is now the core24 grade:stable build (core26 variant kept
  as snapcraft-core26.yaml); release publishes to candidate + edge.
- Add s390x, ppc64el, riscv64 platforms as FerretDB-only (mongod not built there);
  new installs default to FerretDB v1 (snap/hooks/install already guards existing
  MongoDB data), DDP_TRANSPORT=sockjs. Fix Caddy ppc64el->ppc64le mapping.

Docs: add docs/Platforms/FOSS/Snap/CPU-platforms.md; update CHANGELOG and the
Sandstorm migration doc.
2026-07-11 15:37:56 +03:00
Lauri Ojansivu 7598859a47 Updated ChangeLog. 2026-07-11 13:20:39 +03:00
Lauri Ojansivu 4a94793f68 At default docker-compose.yml, changed DDP_TRANSPORT from uws to sockjs, because uws does not work at s390x.
Thanks to xet7 !
2026-07-11 13:20:39 +03:00
github-actions[bot] d4b19389eb Bump versions for v9.85 2026-07-11 09:05:59 +00:00
Lauri Ojansivu b24bf69d6c Updated ChangeLog. 2026-07-11 11:56:16 +03:00
Lauri Ojansivu 5cc9b1dcbb Docker: add "Build from source & start" menu action; remove obsolete compose version: attribute
Add a "Build from source & start (up -d --build)" action to the Docker
menus in rebuild-wekan.sh and rebuild-wekan.bat. It builds the wekan-app
image from the local Dockerfile, tags it as the image the chosen compose
file references (ghcr.io/wekan/wekan:latest), and starts that freshly
built container instead of a possibly-stale prebuilt image pulled from
the registry - useful when a source change (e.g. the FerretDB
Version-page detection) is not in the pulled image yet.

Remove the obsolete top-level "version:" attribute from all docker
compose files, which Docker Compose v2 warns about and ignores:
docker-compose.yml, docker-compose-ferretdb-v2-postgresql.yml,
docker-compose-multitenancy.yml, .devcontainer/docker-compose.yml, and
docs/Databases/ToroDB-PostgreSQL/docker-compose.yml.

Thanks to xet7 !
2026-07-11 11:52:03 +03:00
Lauri Ojansivu 4dbf99010c Docker: make FerretDB v1 + SQLite the default docker-compose.yml
Rename docker-compose-ferretdb-v1-sqlite.yml -> docker-compose.yml (the
new default) and the old MongoDB docker-compose.yml ->
docker-compose-mongodb-v7.yml, so "docker compose up -d" uses the light,
self-contained FerretDB v1 + embedded SQLite from
https://github.com/wekan/FerretDB by default.

Update rebuild-wekan.sh and rebuild-wekan.bat Docker menus to list
FerretDB v1 SQLite first (default) and point at the new filenames.
Update the compose files' own header comments (self-identification and
cross-references), note the new default at the top of docker-compose.yml,
and update the Docker docs (main Docker.md gets a compose-file/database
map; FerretDB2-PostgreSQL.md points at the v2 postgresql compose file).

Thanks to xet7 !
2026-07-11 11:35:47 +03:00
Lauri Ojansivu 544e1b14b4 rebuild-wekan.sh/.bat: Dev server also frees rspack :8080 on restart; add "Kill all dev servers"
The Dev server options killed only the app port, but "meteor run" also
starts an rspack dev server on 8080 that can outlive the meteor parent.
A leftover one made restarting a dev server crash with
"Error: listen EADDRINUSE ... :8080". The kill now frees both the app
port and the rspack port 8080 (Meteor.devServerPort || 8080), matching
the rspack watcher by its devServerPort env.

Port detection now checks the listening socket directly (ss/lsof with a
bash /dev/tcp fallback that needs no external tools; netstat on .bat)
instead of an HTTP probe, so it also catches a server that is still
building and not yet serving. Fixes a hang where lsof did reverse-DNS
without -nP.

Add a new "Dev server -> Kill all dev servers" menu option that frees
every dev/test port the scripts use at once: dev app 3000 + Mongo 3001,
Mocha test server 3100 + Mongo 3101, Sandstorm standalone dev 4000 +
Mongo 4001, and rspack 8080. It kills meteor, the rspack watcher and
Meteor's bundled --replSet meteor Mongos, never a production/system
Mongo. Both scripts escalate to SIGKILL if a port does not free up.

Update CHANGELOG and Build-from-source.md docs.

Thanks to xet7 !
2026-07-11 11:30:25 +03:00
Lauri Ojansivu 5ef2adf6fb rebuild-wekan.sh/.bat: Dev server options kill existing Meteor on the port; docs for new menu
Every "Dev server" menu option now stops any Meteor dev server already
listening on its port before starting a fresh one, so re-running a dev
option no longer fails because the port is taken. .sh adds a reusable
kill_meteor_on_port helper called by all six dev options; .bat funnels
them through :runlog, which calls a new :kill_meteor_on_port (netstat +
taskkill). Both wait for the port to free up and escalate to SIGKILL.

Also update the build-from-source docs (README.md, Build-from-source.md,
Build-and-Create-Pull-Request.md, Emoji.md, and the two Sandstorm
developer docs) to the new two-level menu (Setup -> Install dependencies,
Setup -> Build WeKan, Dev server -> localhost:3000), fix a stale
dev-server port (localhost:4000 -> 3000), and fix the README anchor link
to the renamed heading. Add a CHANGELOG entry.

Thanks to xet7 !
2026-07-11 11:11:22 +03:00
Lauri Ojansivu ccc03913fc i18n(fi): translate Sandstorm 'grain' as 'jyva' (jyvassa/jyvan)
Use the Finnish term jyva for Sandstorm grain in the sandstorm-migration
strings: Sandstorm-jyvassa, jyvan hakemistoihin, kayttavat jyvassa, jyvan
levytilan.
2026-07-11 10:47:13 +03:00
Lauri Ojansivu af62ebd97a i18n: Finnish translations for the Sandstorm migration admin panel strings
Translate the 12 sandstorm-migration-* strings in fi.i18n.json, matching the
existing WeKan Finnish terminology (siirto, Liitetiedostot, Profiilikuvat,
tiedostojärjestelmä, Poista, Tallennustila). The proper noun Sandstorm and the
literal paths/DB names are kept as-is.
2026-07-11 10:43:54 +03:00
Lauri Ojansivu f25c6553bd Updated translations 2026-07-11 10:40:13 +03:00
Lauri Ojansivu c72ae4cf05 i18n: don't translate the project name 'Sandstorm' (hardcode it)
Remove the en.i18n.json key sandstorm-migration whose value was the bare
proper noun 'Sandstorm' (the https://sandstorm.org project name), and hardcode
'Sandstorm' in the Admin Panel / Attachments tab label and panel title instead.
The other sandstorm-migration-* keys are real translatable strings and stay.
2026-07-11 10:35:01 +03:00
Lauri Ojansivu 43dcf56a3b rebuild-wekan.sh/.bat: group menu into category submenus + two-stage Docker menu
Replace the long flat menu with a short top-level menu (Setup / Dev server /
Tests / Docker / Tools / Quit); each opens a small submenu with 0) Back, so
only a few items show at once and labels are shorter (category gives context).
Docker becomes a two-stage submenu: pick backend, then Start (up -d) / Follow
logs (logs -f) / Stop (down) -- removing 12 near-identical flat entries.

All action handlers are unchanged; only the front-end dispatch is regrouped:
.sh keeps every case handler and reaches them via a choose() 'short|full' map
(dispatch wrapped in 'for _once in 1'); .bat routes category submenus to the
existing :labels. Verified: .sh bash -n clean + all 23 menu labels map to a
handler; .bat has no orphan gotos.
2026-07-11 07:31:23 +03:00
Lauri Ojansivu 1778929869 rebuild-wekan.sh/.bat: add Follow logs / Stop docker menu options per compose file
Expands the docker-compose menu so each backend (MongoDB docker-compose.yml,
FerretDB v1 SQLite, FerretDB v2 PostgreSQL, MongoDB Multitenancy) has three
grouped entries -- Start (up -d), Follow logs (logs -f) and Stop (down) --
instead of just Start. The .sh uses a generalized wekan_docker helper that
auto-detects 'docker compose' vs legacy 'docker-compose'.
2026-07-11 07:04:14 +03:00
Lauri Ojansivu d50277721c rebuild-wekan.sh/.bat: add menu options to start WeKan Docker per compose file
New menu entries run 'docker compose -f <file> up -d' for each backend:
MongoDB (docker-compose.yml), FerretDB v1 SQLite, FerretDB v2 PostgreSQL,
and MongoDB Multitenancy. Each prints follow-logs + stop commands. The .sh
auto-detects 'docker compose' vs legacy 'docker-compose'.
2026-07-11 06:57:48 +03:00
Lauri Ojansivu f1749b2af7 docker-compose ferretdb v1-sqlite: drop leftover MONGODB_LOG_* debug env
The MONGODB_LOG_PATH/TOPOLOGY/SERVER_SELECTION/CONNECTION=debug vars were
temporary mongodb-driver diagnostics that got committed by accident; they
flood wekan-app logs with topology JSON. Remove them from the example compose.
2026-07-11 06:49:16 +03:00
Lauri Ojansivu d0d98bcadc FerretDB: --log-level=error to silence connection WARN spam; remove dead MongoDB-driver subsystem
Driver debug logs showed a 2nd, TLS-enabled Mongo monitor connection
retrying every ~0.5s against the plaintext FerretDB port; FerretDB logged
each rejection at WARN. Harmless (WeKan uses the real plaintext connection)
but noisy. Start FerretDB with --log-level=error everywhere it is launched
(Docker entrypoint, snap ferretdb-control, release start-wekan.sh, the
v1-sqlite compose example).

Also remove the dead, unused MongoDB Driver System (server/mongodb-driver-startup.js
and models/lib/{meteorMongoIntegration,mongodbConnectionManager,mongodbDriverManager}.js)
- an abandoned MongoDB 3.0-8.0 auto-detect/driver-select experiment whose
driver packages were never installed. WeKan uses the mongodb-7 driver via Meteor.

gitignore the local FerretDB/ reference clone.
2026-07-11 06:28:30 +03:00
Lauri Ojansivu 743a47c462 snap: install Caddy from GitHub release binary, not Cloudsmith apt repo
The caddy part fetched Caddy from the Cloudsmith apt repo
(curl dl.cloudsmith.io | gpg --dearmor), which fails on Launchpad remote
builders (restricted network via a fetch proxy): gpg got no key
('no valid OpenPGP data found') and override-build failed with code 2 on
both arches. Download the official prebuilt vanilla Caddy static binary from
GitHub releases instead (per-arch, latest stable + pinned fallback). WeKan
uses only built-in Caddy directives, so no apt repo, gpg, or xcaddy needed.
Applied to both snapcraft.yaml and snapcraft-core24.yaml.
2026-07-11 05:56:25 +03:00
Lauri Ojansivu cfa5707a40 Admin Panel / Version: detect FerretDB via top-level ferretdbVersion
The wekan/FerretDB v1 fork's buildInfo reports a top-level ferretdbVersion
string (e.g. v1.24.2-60-gb5523566) + ferretdbFeatures, and its commit in
gitVersion -- not a ferretdb sub-document. Detection only checked
buildInfo.ferretdb, so the Version page showed Database type: MongoDB and
hid the FerretDB rows. Handle both shapes: top-level ferretdbVersion/
ferretdbFeatures and the ferretdb sub-document. Also confirm in the Sandstorm
doc that FerretDB writes <dir>/wekan.sqlite (+ -shm/-wal).
2026-07-11 05:40:14 +03:00
Lauri Ojansivu b8a7a2e114 Sandstorm: keep niscu migration, build from upstream 0.6.0 (drop projects.7z/releases.wekan.team)
- start.js: chain niscu (2.x) -> MongoDB 3.0 -> FerretDB; keep niscud + old
  bundled driver for the niscu->3.0 stage. Migration from old versions is
  permanent (not dropped).
- build-deps.sh (new): assemble a modern meteor-spk.deps on top of upstream
  meteor-spk 0.6.0 from dl.sandstorm.io (already has mongod 3.0 + niscud);
  swap in Node 24, add FerretDB + Mongo 3.x CLIs + launcher/importer, keep
  niscud. Extra binaries from GitHub releases. Nothing needed from projects.7z.
- sandstorm.yml: replace the dead releases.wekan.team/projects.7z download with
  build-deps.sh.
- sandstorm-pkgdef.capnp: WRITABLE_PATH -> /var/files.
- docs: keep-niscu + build-source corrections; migration support is permanent.
2026-07-11 05:07:43 +03:00
Lauri Ojansivu 1237e4c2de Sandstorm: add grain launcher sandstorm-src/start.js (Node24 + FerretDB + migration)
CJS meteor-spk.deps launcher: on grain start, if old MongoDB 3.0 data exists and
migration is not yet done, start mongod 3.0 (wait for ready), start FerretDB v1
(SQLite), run migrate-mongo3-to-ferretdb (text -> FerretDB, GridFS attachments/
avatars -> /var/files), mark done (old data kept); then run WeKan (Node 24) on
FerretDB steady-state. Paths resolve from __dirname (grain mount root). Update
the design doc to point at the canonical file and mark the in-app panel done.
2026-07-11 04:50:36 +03:00
Lauri Ojansivu 61792f59c1 gitignore: ignore local projects/ and sandstorm/ build working copies
projects/ (unarchived meteor-spk fork binaries) and sandstorm/ (clone of
sandstorm-io/sandstorm) are large, machine-specific working copies used only
when building the Sandstorm .spk; never commit them.
2026-07-11 04:47:42 +03:00
Lauri Ojansivu 664c6e1943 Sandstorm: Admin Panel / Attachments / Sandstorm migration panel
When isSandstorm, show a new Sandstorm section in Admin Panel / Attachments:
one-time MongoDB 3 -> FerretDB migration status, disk space used by the raw
MongoDB 3 files / FerretDB SQLite / attachments / avatars in the grain, and a
guarded button to delete the redundant raw MongoDB files (only after a
confirmed-successful migration, behind a confirm) to free grain disk space.

- server/methods/sandstormMigration.js: admin-gated sandstormMigrationStatus
  + sandstormDeleteRawMongo (isSandstorm-gated, env-overridable grain paths).
- client Attachments: new Sandstorm tab (jade + js), shown only when isSandstorm.
- imports/i18n/data/en.i18n.json: sandstorm-* UI strings.
- migrate-mongo3-to-ferretdb.mjs: optional STATUS_FILE with the final migration
  state, which the panel reads (shared by snap + the Sandstorm spk).
2026-07-11 04:39:24 +03:00
Lauri Ojansivu 9d90b869cd docs: move Sandstorm Meteor3 migration doc under FOSS/Sandstorm
Existing Sandstorm docs live under docs/Platforms/FOSS/Sandstorm/; move the
new Meteor3/Migration.md there (was mistakenly at docs/Platforms/Sandstorm/),
fix its relative links (one level deeper) and the CHANGELOG path.
2026-07-11 04:39:01 +03:00
Lauri Ojansivu 453770ef98 docs: Sandstorm Meteor3/Node24 + MongoDB3->FerretDB migration design
Add docs/Platforms/Sandstorm/Meteor3/Migration.md: how to build a modern
Sandstorm .spk (Node 24, FerretDB v1 SQLite) migrating an existing grain's
MongoDB 3.0 data on first launch, reusing the snap's migrate-mongo3-to-ferretdb
logic. Includes seccomp compatibility analysis, rewritten start.js, and a new
isSandstorm-only Admin Panel / Attachments / Sandstorm section design.
2026-07-11 04:29:24 +03:00
github-actions[bot] a7f9018a60 Bump versions for v9.84 2026-07-11 00:21:01 +00:00
Lauri Ojansivu b6fe0a7256 Updated ChangeLog. 2026-07-11 03:11:16 +03:00
Lauri Ojansivu 02e3c35d69 Snap: show correct writable path for parallel installs in backup help
wekan.help backup/migration instructions hardcoded
/var/snap/wekan/common/files, which is wrong for a parallel snap install
(e.g. wekan_customer -> /var/snap/wekan_customer/common). Print
$SNAP_COMMON/files instead, which snapd sets per instance. Display-only;
all functional snap paths already derive from $SNAP_COMMON/$SNAP_DATA.
2026-07-11 03:03:23 +03:00
Lauri Ojansivu 4766b83da7 Fix flaky server-side Mocha i18n test (TAPi18n .loadLanguage)
The .loadLanguage suite stubbed addResourceBundle on the shared
TAPi18n.i18n singleton and asserted the call count. loadLanguage()
reads this.i18n late and each test re-init()s the singleton, so
cross-test state could leave the stub on a different i18next instance
than the bundle was registered on, making the count read 0 and the run
intermittently report 'expected addResourceBundle to be called once'.
Assert on observable i18next state (hasResourceBundle/getResourceBundle
under the normalised toI18nCode) instead, and stop a leaked
Tracker.autorun in the .getLanguage reactive test.
2026-07-11 02:40:34 +03:00
Lauri Ojansivu 011c172c8c Fix scheduled backup cron init crashing at Meteor 3 server startup
registerCron() used the synchronous BackupSettings.findOne/upsert, which
Meteor 3 forbids on the server, so cron init threw and scheduled backups
never registered. Use findOneAsync/upsertAsync, make registerCron async,
and await it in all callers. Add tests/backupCron.test.cjs (positive +
negative + source guard) to the test:unit:node suite.
2026-07-11 01:53:07 +03:00
xet7 cb18f9b735 Updated ChangeLog. 2026-07-11 01:31:38 +03:00
xet7 abe8af0788 Fix backup build: archiver@8 ESM (ZipArchive) + add @aws-sdk/lib-storage dep
The Meteor/rspack build failed on server/methods/backup.js:
- archiver@8 is now pure ESM with no default export and no archiver('zip', …)
  factory — it exports classes. Use `import { ZipArchive } from 'archiver'` and
  `new ZipArchive({ zlib: { level: 6 } })` (the streaming API — directory/append/
  finalize/pipe/on — is inherited from the Archiver core class).
- The S3 streaming upload requires @aws-sdk/lib-storage, which was not a
  dependency (only @aws-sdk/client-s3 was), so rspack failed with
  "Can't resolve '@aws-sdk/lib-storage'". Add it, pinned to ~3.1073.0 to match
  the installed @aws-sdk/client-s3 (3.1073.0) and avoid the newer lib-storage's
  peer requirement on a newer client-s3.
2026-07-11 01:31:05 +03:00
xet7 997f1fb9df Updated ChangeLog.
Refresh the Upcoming section: re-point every commit link to the current hash
(the earlier links referenced pre-rebase hashes that are not on GitHub), and add
bullets for the newest commits — Transifex duplicate-key fix, Finnish
translations, unit + negative tests, and the Alternative-Architectures doc.
2026-07-11 00:51:44 +03:00
xet7 9828081c76 docs: add Design/Multiverse/Alternative-Architectures.md
Document which CPU architectures WeKan can run on and why, the architecture
matrix (Node 24 vs MongoDB vs FerretDB v1), why armhf/armv7l/i386/loong64 are
not supported (Node dropped 32-bit; QEMU/base-image gaps for loong64), the
JavaScript-engine alternatives (Deno/Bun cover fewer arches; QuickJS/JSC/
SpiderMonkey/JVM run on 32-bit but cannot run Meteor), and the server-rewrite
options (Go recommended, QuickJS niche, Tcl/Tk a poor fit for a web app), plus
hardware notes (ODroid-U3, Raspberry Pi OS 32-bit, ReactOS).
2026-07-11 00:46:13 +03:00
xet7 d2ec3ee400 Unit + negative tests for newest features (lazy loading, streaming export, backup)
Extract the pure, security- and correctness-critical logic of the recent features
into Meteor-free models/lib/* modules (so production and tests share one
implementation) and add plain-Node tests with positive AND negative cases, wired
into test:unit:node:

- models/lib/mongoSelectorSafety.js hasWhere() — the windowed card publication
  refuses a client selector carrying $where (server-side JS). Tests: safe
  selectors pass; $where at top level / in $and / deep in arrays / in $elemMatch
  is rejected. (was inline in server/publications/cardsWindow.js)
- models/lib/cardsLoading.js resolveCardsLoadingMode / parseCardsLoadingEnv /
  windowCountId — CARDS_LOADING maps to 'lazy' only for exactly "lazy" (garbage
  never enables the experimental mode); a falsy swimlane id collapses to one
  count-doc id (listBody uses undefined, listHeader uses ''). (used by
  server/cards-loading.js, server/models/settings.js, client/lib/lazyCards.js)
- models/lib/base64Chunk.js encodeAligned/encodeFinal — streaming an attachment
  into the JSON export as base64 in any chunk sizes reconstructs exactly the
  whole-file base64; aligned pieces never carry padding. (used by
  Exporter.buildStream)
- models/lib/backupPaths.js filesRootFrom/scheduleText — WRITABLE_PATH -> files
  root (no double /files suffix, Windows-safe) and the synced-cron schedule text
  (daily/weekly/monthly + defaults, unknown frequency falls back to daily). (used
  by server/methods/backup.js)

41 new assertions across 4 files, all passing; whole test:unit:node suite green.
2026-07-11 00:14:49 +03:00
xet7 43006f9025 Finnish translations for newest features (Version, migration, backup, lazy loading)
Translate the remaining English strings for the CHANGELOG "Upcoming" features into
Finnish, matching existing terminology (Liitetiedostot, Profiilikuvat, Tallennustila,
Palauta, tiedostojärjestelmä, kokoelma, Tietokannan siirrot):
- Admin Panel / Version: Database_type/commit, FerretDB_version/commit,
  Reactivity_mode, MongoDB_version.
- Admin Panel / Attachments / Database migration (MongoDB <-> FerretDB v1).
- Admin Panel / Features: Card loading (all / lazy) + descriptions.
- Admin Panel / Attachments / Backup: schedule, storage, restore, list.
Product names and acronyms (FerretDB, MongoDB, SQLite, GridFS, S3/MinIO, Gantt,
LDAP, OAuth2, CAS) are kept as-is. 44 keys translated.
2026-07-10 23:56:07 +03:00
Lauri Ojansivu fcfd5bce9b Updated translations 2026-07-10 23:50:21 +03:00
xet7 4cbefed339 i18n: remove duplicate database-migration keys (Transifex parse_error)
en.i18n.json had two "database-migration" and two "database-migration-description"
keys, so the Transifex push failed with "Duplicate string key". The later
(block-2) copies — "Database Migration" / "Updating database structure..." — were
unused (only the Admin Panel / Attachments migration UI references these keys, and
it needs the MongoDB<->FerretDB wording in the earlier copy). Removed the unused
duplicates; JSON now parses with unique keys.
2026-07-10 23:40:11 +03:00
xet7 7d594c95ae Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 7d5f29e114 Lazy card loading: accurate card counters and WIP limits
In lazy mode minimongo holds only the loaded window, so the list card-counter
badge and the WIP-limit checks (reachedWipLimit / exceededWipLimit, the add-card
gate, and setting the WIP limit to the current count) previously reflected only
the cards loaded so far. They now use lazyListCardCount(), which subscribes to
and reads the server-side count for the SAME selector list.cards(swimlaneId) uses
(listCardsSelector + the board Filter) — so the count is exactly the accurate
value list.cards(swimlaneId).length would report if every card were loaded. The
template's whole-list count uses (cards.length) are routed through a new
cardsCountWhole helper the same way. Default 'all' mode falls back to the loaded
length and is unchanged.
2026-07-10 22:57:40 +03:00
xet7 29b617769a Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 f0d25a2431 Expose CARDS_LOADING on all platforms (docker-compose, launchers, snap)
Adds the CARDS_LOADING=all|lazy knob everywhere WeKan is configured:
- docker-compose.yml and the two FerretDB compose files (v1-sqlite, v2-postgresql)
- releases/ferretdb/start-wekan.sh and start-wekan.bat (bundle launchers)
- releases/virtualbox/start-wekan.sh
- snap: added to snap-src/bin/config keys + triplet, so
  "snap set wekan cards-loading=lazy" works, with an example in wekan-help.
All default to 'all' (unchanged behaviour).
2026-07-10 22:57:40 +03:00
xet7 09805a8a91 Lazy card loading (client) + Admin Panel / Features section
Client side of CARDS_LOADING=lazy. In lazy mode each list's cardsWithLimit
subscribes to boardCardsWindow for just the cards it renders (growing with the
infinite-scroll limit) and to boardListCardCount for the total; showSpinner uses
that server count to decide whether more cards remain, since minimongo now holds
only the loaded window. Default 'all' mode is unchanged (helpers short-circuit on
isLazyCards()).

Adds Admin Panel / Features — a new admin section (route /admin-features, nav
entry, adminFeatures template) intended as the home for optional / performance /
future tier-gated capabilities. First control: "Card loading" (all | lazy), which
writes the cardsLoading Setting; it mirrors, and is overridden on startup by, the
CARDS_LOADING env var. Includes an experimental-note (counters/WIP limits and the
Calendar/Table/Gantt views reflect only loaded cards; reload boards after change)
and the i18n keys.
2026-07-10 22:57:40 +03:00
xet7 6f7ad270d1 Lazy card loading (server): CARDS_LOADING=all|lazy + windowed publications
Adds an opt-in card-loading mode for very large boards. Default 'all' is
unchanged: the board publication still ships every card into minimongo.

When CARDS_LOADING=lazy (env) or Admin Panel / Features = lazy:
- The board publication no longer ships all cards, their comments/attachments,
  or the board-level checklists/checklistItems (gated by a lazyCards() check).
- A new publish-composite 'boardCardsWindow' publishes only the cards of one
  list/swimlane window (the client passes the exact selector it renders with,
  which is ANDed with a server-forced { boardId, archived:false } scope and
  refuses $where), plus each visible card's comments, attachments, checklists
  and checklist items. As the user scrolls the client raises the limit and
  re-subscribes, streaming more cards in on demand.
- A 'boardListCardCount' publication reactively publishes each window's total
  card count into the client-only boardListCardCounts collection, so the client
  knows whether more cards remain to load.

The mode is a Settings field (cardsLoading), seeded from the CARDS_LOADING env
var (authoritative on startup when set) and mirrored onto
Meteor.settings.public.cardsLoading for the publications to read; an
after.update hook keeps it in sync when an admin changes it.
2026-07-10 22:57:40 +03:00
xet7 bd1c611688 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 ae9be62ef3 Export board to HTML .zip: stream to disk and include every card
The client HTML export cloned the live DOM and built the whole .zip as an
in-memory blob. Two problems on large boards: infinite scroll keeps only ~10
cards per list in the DOM, so most cards were missing from the export; and the
entire archive was buffered in browser RAM.

Before snapshotting, every list's cardlimit is now lifted so the whole board
renders (then we wait for Blaze to flush). The zip is written with JSZip's
generateInternalStream piped straight to the chosen file via the File System
Access API, chunk by chunk with backpressure, so the archive is never held
whole in memory; browsers without the API fall back to the previous blob
download.
2026-07-10 22:57:40 +03:00
xet7 41cd166358 Stream board exports (JSON, CSV/TSV, Excel) with bounded memory
The board export routes buffered the entire board in memory before sending:
JSON export built one object with every card, comment, activity, checklist and
base64 attachment; CSV called that same build(); Excel additionally loaded data
it never renders (activities/checklists/subtasks/rules) and did O(n^2)
result.cards.find() lookups. On big boards this peaked at gigabytes and could
exceed V8's max string length.

Now:
- Exporter.buildStream(res) writes the JSON document straight to the response a
  document at a time from raw Mongo cursors, encoding each attachment file to
  base64 in 3-byte-aligned chunks. Same _format/keys, so import round-trips.
- Exporter.buildCsvStream(res) keeps only the small lookup tables in memory and
  streams cards from a cursor, writing one CSV/TSV row at a time.
- ExporterExcel uses the exceljs streaming WorkbookWriter, committing each row
  to the response, drops the unused collections, and resolves parent/comment
  card titles via an id->title map instead of scanning all cards.
Peak memory stays flat regardless of board size.
2026-07-10 22:57:40 +03:00
xet7 e39dcd4ebf Backup: switch from jszip to archiver+unzipper for low-memory streaming
Admin Panel / Attachments / Backup no longer loads whole files or the whole
zip into memory. The backup zip is written with archiver, streaming each
attachment/avatar straight from disk (archive.directory) and each text
collection a document at a time from a Mongo cursor as NDJSON, piped directly
to the destination (filesystem or S3/Azure/GCS streaming upload) with no temp
file. Restore reads the zip with unzipper, piping each file entry to disk and
each NDJSON data entry line-by-line in 200-doc batches. A board with thousands
of cards or a 5 GB attachment now backs up/restores with flat memory.
2026-07-10 22:57:40 +03:00
xet7 4f1a60aa7b Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 5757cfc8ca Add standalone sandstorm.yml workflow; remove build-sandstorm from release-all.yml
Sandstorm packaging is not tested well enough yet, so it should not run as part of
a full release. Remove the build-sandstorm job from release-all.yml, and add a
separate, manually-triggered sandstorm.yml that ONLY builds the Sandstorm .spk and
uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the newest WeKan GitHub
Release (found via `gh release list`), plus a workflow artifact — so it can be
downloaded and tested for errors without affecting releases. Same experimental
caveats (unprivileged user namespaces, SANDSTORM_KEYRING signing secret).

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 c7c8a200ff Admin Panel / Attachments / Backup: scheduled backups of Attachments/Avatars/Data, streamed to storage; restore + list
Adds a Backup section to Admin Panel / Attachments:
- Checkboxes to select Attachments, Avatars and/or Data (all text-based
  collections that are not attachments/avatars).
- Storage dropdown (filesystem, S3/MinIO, Azure, GCS).
- Backup now: the .zip is STREAMED DIRECTLY to the selected storage — no temp
  file, no extra disk — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip containing
  YYYY_MM_DD-HH_MM_SS/{attachments,avatars,data/<collection>.json}. Filesystem
  pipes to the file; S3 uses @aws-sdk/lib-storage streaming Upload, Azure
  uploadStream, GCS createWriteStream (cloud creds from the storage tabs).
- Scheduler (off/daily/weekly/monthly + time/day) saved and run via synced-cron.
- List backups: table with storage, datetime, path; select one and Restore with
  "Add missing data only" or "Replace all data".

Server methods (admin-only): runBackup, restoreBackup, listBackups,
getBackupSchedule, saveBackupSchedule, backupStatus (polled for live progress).

The cloud upload paths and restore are not exercised end-to-end here; the whole
zip is assembled with jszip, so very large attachment sets use notable memory.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 71fcf2b454 Make migrate-mongo3-to-ferretdb.mjs executable 2026-07-10 22:57:40 +03:00
xet7 1dbc26d200 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 84fee7b660 Admin Panel / Attachments: migrate text data between MongoDB and FerretDB v1 (SQLite), both directions
Adds a "Database migration" section to Admin Panel / Attachments with two buttons:
migrate text-based data (everything except attachments/avatars, which stay on the
filesystem) to FerretDB v1 (SQLite), or back to MongoDB.

WeKan is connected to one database at a time, so the new server methods open a
SECOND driver connection to the OTHER database (both MongoDB and FerretDB v1 speak
the MongoDB wire protocol) and copy the text collections from the live database
into it, upserting by _id (idempotent, re-runnable). The target URL is
WEKAN_FERRETDB_URL (default mongodb://127.0.0.1:27018/wekan) or WEKAN_MONGODB_URL
(default mongodb://127.0.0.1:27019/wekan); both must be running. Progress is polled
via migrateTextDatabaseStatus and shown live. After it finishes, point MONGO_URL at
the other database and restart (on Snap: snap set wekan database=ferretdb / =mongodb).

Admin-only. Attachments/avatars are skipped (they live on the filesystem).

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 3b7a276347 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 02cf16d8c6 Snap migration: only MongoDB 3 migrates (via mongo CLI read + Node driver insert), FerretDB SQLite at files/db
Per the design: a MongoDB 7 database works with newest WeKan as-is and is NOT
migrated; only the old WeKan 6.09 / MongoDB 3.2 data is migrated to FerretDB v1.

- migration-control now: if mongod 7 can open the existing $SNAP_COMMON data it is
  MongoDB 6/7 -> no migration (mark done, keep MongoDB); otherwise it is MongoDB
  3.x -> migrate to FerretDB. The dump/restore-into-mongod-7 hop is removed.
- New importer migrate-mongo3-to-ferretdb.mjs reads MongoDB 3.2 with the legacy
  mongo CLI (mongoexport — the modern Node driver can't talk to 3.2) and inserts
  into a running FerretDB with the Node driver: text data streamed directly, and
  CollectionFS/Meteor-Files GridFS attachments+avatars reassembled per-file from
  exported chunks straight to files/attachments and files/avatars (no duplicate
  copy, no mongodump/mongorestore, no intermediate MongoDB 7). Progress at ROOT_URL.
- FerretDB SQLite now lives at <files>/db, next to attachments/avatars (the
  "files/<name>" layout), in ferretdb-control, migration-control, the offline
  start-wekan.sh/.bat, the Docker entrypoint and docker-compose-ferretdb-v1-sqlite.

The amd64 6.09/MongoDB-3.2 path is not yet tested against real 6.09 data.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 54944717ee Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 b99d21d9f1 Snap migration: read MongoDB 3.2 via migratemongo CLI (dump -> restore into mongod 7), fix migrator NODE_PATH
The bundled Node MongoDB driver cannot connect to a MongoDB 3.2 server, and no
single driver version spans 3.2 and MongoDB 7 / FerretDB. So instead of aliasing
an old (EOL) Node driver into package.json, use the migratemongo CLI tools for the
amd64 6.09/3.2 case: migration-control now dumps the 'wekan' database with the old
migratemongo mongodump (MongoDB 3.2, old libraries), then loads it into a fresh
temporary mongod 7 with the snap's modern mongorestore. The existing driver-based
migrator then reads that mongod 7 exactly like the arm64 MongoDB-7 case (both
GridFS types -> filesystem, text -> FerretDB v1 SQLite). The MongoDB-7 path
(arm64 + amd64-on-newest-WeKan) connects the driver directly, unchanged.

Also fix a real bug: the standalone migrator script in $SNAP/bin could not resolve
its `mongodb`/`bson` imports; set NODE_PATH to the WeKan bundle's node_modules.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 3dfd84973c Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 dd4611e43f Snap migration: bundle migratemongo (MongoDB 3.2 binaries + old libs + AVX wrappers) and use it to read 6.09 data
Per docs/Backup/Backup.md and https://github.com/wekan/migratemongo, running the
old MongoDB tools/server in the snap needs LC_ALL=C, the mongo binaries on PATH,
and their libraries on LD_LIBRARY_PATH ($SNAP/lib/<arch>-linux-gnu). The 2016
MongoDB 3.2 binaries additionally need old libraries (libssl/libcrypto.so.1.0.0,
libpng12, libexpat) that modern bases lack.

- Add a migratemongo snapcraft part (both snapcraft.yaml and snapcraft-core24.yaml)
  that stages https://github.com/wekan/migratemongo at $SNAP/migratemongo (bin/ =
  MongoDB 3.2 binaries, lib/x86_64-linux-gnu/ = the old libraries, avx/ = QEMU
  wrappers for non-AVX CPUs), amd64 only. This also fills in the
  $SNAP/migratemongo/avx path that mongodb-control / mongodb-backup /
  mongodb-restore already referenced but was never bundled.
- Replace the naive fresh-download mongodb-old part (a bare 3.2 mongod with no old
  libs) with this.
- migration-control now reads the amd64 6.09 MongoDB 3.2 data with
  $SNAP/migratemongo/bin/mongod, LD_LIBRARY_PATH=$SNAP/migratemongo/lib/..., and
  the legacy $SNAP/migratemongo/bin/mongo shell (mongosh cannot talk to 3.2). The
  MongoDB 7 path (arm64 + amd64-on-newest-WeKan) still uses the snap's mongod 7 +
  mongosh.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 69e6b8bcd1 Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 6ab29dc81e Snap: one-time MongoDB -> FerretDB v1 migration on upgrade (arm64 Mongo7 + amd64 6.09)
On first boot after upgrading an old MongoDB-based WeKan snap, mongodb-control now
hands off to a new migration-control before starting mongod:

- Detects existing MongoDB data in $SNAP_COMMON and opens it with the right
  bundled mongod: mongod 7 for MongoDB 7 data (all arches, including the arm64
  snaps already on newest WeKan), or a bundled old mongod 3.2 (amd64 only, new
  mongodb-old part) for WeKan 6.09 / MongoDB 3.2 data.
- Starts a temporary FerretDB v1 (SQLite) and runs migrate-mongodb-to-ferretdb.mjs,
  which migrates text data to FerretDB and BOTH CollectionFS GridFS and
  Meteor-Files GridFS attachments+avatars to the filesystem, shows a live progress
  counter at ROOT_URL, checkpoints to $SNAP_COMMON (resumable), and compacts the
  old MongoDB when done.
- On success switches the snap to database=ferretdb.

Idempotent (migration-pending helper + completion marker), resumable, never
deletes the source MongoDB data, only switches on success. The migrator is bundled
into the snap (/bin). Progress dashboard title fixed to 'MongoDB -> FerretDB'.

NOT yet tested end-to-end against a real 6.09 snap; in particular the 2016 mongod
3.2 binary may need extra runtime libraries on a modern base (amd64 6.09 path).
The arm64/MongoDB-7 path uses the already-bundled mongod 7.

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 9ae6a91daf Fix compose commit link in ChangeLog 2026-07-10 22:57:40 +03:00
xet7 c3674330ac Updated ChangeLog. 2026-07-10 22:57:40 +03:00
xet7 be5ddd77ef Rename docker-compose-ferretdb.yml to ...-v2-postgresql.yml and add ...-v1-sqlite.yml
- docker-compose-ferretdb.yml -> docker-compose-ferretdb-v2-postgresql.yml
  (FerretDB 2 + PostgreSQL/DocumentDB, unchanged database).
- Add docker-compose-ferretdb-v1-sqlite.yml: FerretDB v1 with the embedded SQLite
  backend, no PostgreSQL/MongoDB. Its ferretdb service fetches the v1 binary for
  its architecture from the newest wekan/FerretDB release
  (https://github.com/wekan/FerretDB/releases), caches it on the volume, and runs
  it with --handler=sqlite.

Both compose files now carry the FULL wekan service from docker-compose.yml
(every documented environment variable and feature); the ONLY differences are
database-related: the ferretdb service replaces the mongodb service, MONGO_URL
points at FerretDB, MONGO_OPLOG_URL is dropped and METEOR_REACTIVITY_ORDER is
polling (FerretDB has no MongoDB change streams / replica-set oplog).

Thanks to xet7.
2026-07-10 22:57:40 +03:00
xet7 9fc88a783c Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 748fd2d391 Snap: migrate Caddyfile v1 -> v2 format when caddy is enabled
When upgrading from an old WeKan snap, $SNAP_COMMON/Caddyfile may still be in
Caddy v1 format, which Caddy 2 cannot parse (so caddy would fail to start).
caddy-control now runs migrate-caddyfile-v1-to-v2.sh before 'caddy run' when
caddy is enabled. It is safe: a no-op if the file already parses as Caddy 2;
otherwise it backs up the original, converts the common v1 directives (drop the
http:// scheme, 'proxy / TARGET' -> 'reverse_proxy TARGET', drop the v1
'websocket'/'transparent' presets that Caddy 2 does by default, 'gzip' ->
'encode gzip'), validates the result with 'caddy adapt', and only keeps it if
valid — falling back to the shipped Caddy 2 template otherwise, so caddy always
starts with valid config.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 4ac24dc546 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 4c630d4ba8 migrate-mongodb-to-ferretdb: resumable progress in WRITABLE_PATH + compact old MongoDB after success
Two additions to the MongoDB -> FerretDB / GridFS -> filesystem migrator used by
the Snap and Sandstorm migrations:

- Resumable: progress is checkpointed to $WRITABLE_PATH/migration-progress.json
  (atomic write) after every collection and file phase. On restart the migrator
  loads the checkpoint and SKIPS collections it already finished, so an
  interrupted migration (snap refresh, Sandstorm grain restart, power loss)
  continues instead of starting over. Safe because every document is upserted.

- Compact after success: once the migration is complete (fresh run, or a later
  boot that finds the completion marker), it runs 'compact' on each source
  MongoDB collection so WiredTiger returns the now-duplicated disk space to the
  OS. Runs once (tracked by a 'compacted' flag on the marker) and is best-effort.

The existing ROOT_URL progress dashboard and disk-space checks (statfsSync) are
kept; the dashboard is restored from the checkpoint on resume.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 a563d3c4b2 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 95b186e50a release-all.yml: add build-sandstorm job to pack and attach wekan-<version>.spk
Mirrors releases/release-sandstorm.sh + releases/install-sandstorm.sh: installs
Meteor, meteor-spk 0.6.0 (from the WeKan projects.7z) and a dev Sandstorm, then
'meteor-spk pack' produces wekan-<version>.spk and attaches it to the GitHub
Release. Decoupled from the release job so it never blocks the core release.

EXPERIMENTAL in CI: Sandstorm needs unprivileged user namespaces (GitHub's Ubuntu
VMs allow them), and meteor-spk signs the .spk with the app private key from the
Sandstorm keyring -> provide it as the SANDSTORM_KEYRING secret. 'spk publish' to
the app market and the scp upload stay manual, as in release-sandstorm.sh.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 bbc525ef27 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 bfb3fd246b Self-contained release bundles: bundle Node.js + FerretDB + start-wekan.{sh,bat}
Each wekan-<version>-<arch>.zip is now fully offline: its bundle/ directory
contains the WeKan server, a Node.js binary for that platform, a FerretDB v1
(SQLite) binary, and a start-wekan.sh (start-wekan.bat on Windows) that by
default runs WeKan on the bundled Node against the bundled FerretDB SQLite,
storing data and attachments/avatars on the filesystem under WRITABLE_PATH
(like docs/Platforms/Propietary/Windows/Offline.md). Override with WEKAN_DB /
MONGO_URL to use an external database.

- build-amd64/arm64/mac copy the runner's own Node 24 (matching arch); the
  emulated ppc64le/s390x/riscv64 builds copy the target-arch Node they already
  download; win64 bundles node.exe. Each overwrites the base bundle's node.
- start-wekan.sh supersedes start-wekan-ferretdb.sh (removed).
- Docker and snap have their own Node + entrypoint/wekan-control, so they strip
  the redundant bundled node + launchers (keeping the ferretdb binary), saving
  ~80 MB per image/snap.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 3a968052b9 Add snapcraft-core24.yaml: newest WeKan on base core24 for the Snap Stable channel
The main snapcraft.yaml uses base core26, which needs build-base devel + grade
devel until core26 is released, so it can only go to Snap Beta/Edge. This adds a
base core24 variant (a released base, grade stable) that builds the SAME newest
WeKan (Meteor 3.5, Node.js 24, FerretDB v1, MongoDB 7, Caddy 2), so the Stable
channel can be updated from the old 6.09 snap. Only base/build-base/grade differ
from snapcraft.yaml; migration wiring is added separately.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 d424bd6386 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 3824066f7f rebuild-all.yml: download prebuilt ferretdb.zip from wekan/FerretDB releases instead of building it, and stop attaching it to WeKan releases
FerretDB is now built and released separately in the wekan/FerretDB repo (its
build.sh cross-compiles every platform into ferretdb.zip and attaches it to a
release there). The WeKan release workflow no longer sets up Go or compiles
FerretDB: build-ferretdb just downloads the newest ferretdb.zip from
https://github.com/wekan/FerretDB/releases/latest and republishes it as a
workflow artifact, and every WeKan build embeds its per-arch binary from that.

ferretdb.zip is no longer attached to the WeKan releases, since it already lives
on the wekan/FerretDB releases.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 774d752620 Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 dbca7cd72d Fix #6445: dynamic-import chunks 404 under a sub-path (build-chunks/build-chunks/)
Under a sub-path deployment (ROOT_URL like https://host/wekan, typically behind a
reverse proxy that strips the prefix), language selection and other lazy-loaded
features failed with:
  Error serving static file ENOENT ... /programs/server/build-chunks/build-chunks/<id>.js

rspack's client runtime builds each chunk URL as __webpack_public_path__ + chunkName,
and chunkName already includes the 'build-chunks/' prefix (u = e => 'build-chunks/'+e+...).
The default public path is '/', so at the site root chunks resolve correctly to
/build-chunks/<chunk>. But client/00-startup.js set the sub-path public path to
_rootPath + '/build-chunks/', so rspack appended a second 'build-chunks/', yielding
/wekan/build-chunks/build-chunks/<chunk> — which 404s. Set the public path to
_rootPath + '/' and let rspack add 'build-chunks/' itself.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 8497e7c537 rebuild-all.yml: build standalone ferretdb.zip (all Go-cross-compilable arches) and source every WeKan build's FerretDB binary from it
Adds a build-ferretdb job that cross-compiles FerretDB v1 (wekan/FerretDB, pure-Go
SQLite backend, CGO off, no QEMU) for every platform Go + modernc.org/sqlite
support — not only the arches Node.js runs on — and packs them into ferretdb.zip:

    ferretdb/<arch>/ferretdb-<arch>        (Linux/macOS/BSD, executable)
    ferretdb/<arch>/ferretdb-<arch>.exe    (Windows only)
    ferretdb/README.md                     (links to https://github.com/wekan/FerretDB)

Targets that do not compile on a given platform are skipped, so the zip contains
exactly what builds. ferretdb.zip is attached to the GitHub Release, and every
WeKan build (amd64, arm64, ppc64le, s390x, riscv64, win64, mac-arm64; and via the
bundle the docker image and snap) now extracts its FerretDB binary from that same
ferretdb.zip, so there is one source of truth. Windows/macOS bundles now include
FerretDB too (previously stripped), tolerant of an arch that FerretDB can't build.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 7f44459c9f Updated ChangeLog. 2026-07-10 22:57:39 +03:00
xet7 d5c69e0259 Admin Panel / Version: show database type, version, commit, storage engine and reactivity mode
getStatistics now reports, in Admin Panel / Version:
 - Database type: MongoDB or FerretDB (detected from buildInfo.ferretdb).
 - MongoDB-compatible version and the server git commit (buildInfo.gitVersion).
 - FerretDB version and commit, when the server is FerretDB.
 - Storage engine: WiredTiger for MongoDB, SQLite for FerretDB.
 - Reactivity mode currently in use: changeStreams / oplog / polling (from
   Meteor's oplog handle; FerretDB has no oplog so it shows polling).

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 598aa8dfa1 Snap: choose MongoDB or FerretDB v1 with 'snap set wekan database=ferretdb'; FerretDB default for new installs; disable mongosh telemetry
Adds a 'database' snap setting (mongodb|ferretdb). A new ferretdb service runs
FerretDB v1 (SQLite) on the same port MongoDB would use; only one database runs
at a time. The configure hook flips the two services when the setting changes,
mongodb-control disables itself when ferretdb is selected, and wekan-control
drops MONGO_OPLOG_URL for FerretDB (which has no replica-set oplog, so WeKan
uses polling) and waits on a plain ping instead of rs.status().

The FerretDB binary ships inside the WeKan bundle the wekan part already
downloads, so no extra build is needed. New installs default to FerretDB on all
platforms via the install hook (fresh installs only; upgrades keep MongoDB, and
a pre-existing MongoDB data dir is detected and kept). Switch with
'snap set wekan database=mongodb'.

mongosh collects anonymized usage analytics by default and the snap invokes it
several times; disable it (writable HOME + disableTelemetry) so nothing phones
home. mongod itself has no phone-home telemetry (Free Monitoring is opt-in).

Thanks to xet7.
2026-07-10 22:57:39 +03:00
xet7 0360ca1583 Fix rebuild-all.yml s390x build; add bundled FerretDB v1 for ppc64le, s390x, riscv64 and to the Docker image
The extra-arch bundle build ran node:24-slim under QEMU, but the official
node:24 image publishes no linux/s390x manifest (only amd64/arm64/ppc64le),
so the s390x leg failed with 'no matching manifest for linux/s390x'. Build
the emulated native-module rebuild on an ubuntu:26.04 base (which publishes
every arch) and install the Node.js binary from nodejs.org instead; riscv64
uses unofficial-builds.nodejs.org (nodejs.org ships no riscv64).

MongoDB Community only ships amd64/arm64 server binaries, so on ppc64le,
s390x and riscv64 WeKan now bundles FerretDB v1 (the wekan/FerretDB fork with
its embedded pure-Go SQLite backend, MongoDB wire protocol). The FerretDB
binary is cross-compiled once for all five arches (CGO off, static, no QEMU)
and embedded in every .zip next to main.js, so amd64/arm64 users can opt in
too via WEKAN_DB=ferretdb. The Docker image now covers all five arches and
auto-starts FerretDB on the MongoDB-less ones. FerretDB telemetry is disabled
and locked (--telemetry=disable, plus DO_NOT_TRACK). armv7l is still excluded:
no Node.js 24 build exists for it anywhere.

Thanks to xet7.
2026-07-10 22:57:39 +03:00
github-actions[bot] 9c51391a75 Bump versions for v9.83 2026-07-09 11:57:12 +00:00
Lauri Ojansivu 2ed13a1593 Updated ChangeLog. 2026-07-09 14:48:08 +03:00
Lauri Ojansivu ca50c9d1aa Updated ChangeLog. 2026-07-09 14:45:09 +03:00
Lauri Ojansivu 548d7b483e Updated ChangeLog. 2026-07-09 14:42:48 +03:00
Lauri Ojansivu 71bbc9f67e Updated ChangeLog. 2026-07-09 14:09:14 +03:00
Lauri Ojansivu 05fd6fd3a5 Fix #3624 swimlane REST regression: await the async board.swimlanes()
The swimlanes CRUD e2e test (23-rest-api-more.e2e.js:209) regressed on all
browsers. Root cause: on the server ReactiveCache.getSwimlanes is async, so
board.swimlanes() returns a Promise in the REST handler. The #3624 change read
board.swimlanes().map(s => s.sort); .map on a Promise throws, the insert never
ran, the error was swallowed by the handler's catch (returned as 200 with no
_id), and the test read .title off a null swimlane.

The earlier "move the require to a top-level import" commit was not the real
cause (the same module.exports import pattern works server-side, e.g.
ruleDeletePermission in rulesButton.js). The actual fix is to await
board.swimlanes() before mapping. This also means API-created swimlanes now get
a real max(existing sort)+1 sort — previously the un-awaited `.length` yielded
undefined, so they were stored with no sort at all, which is the #3624 symptom.

Verified: the swimlane test passed in every kept local run through
2026-07-09_02-38-07 and failed starting 09-52-25 (right after the #3624 change),
confirming the regression window; the tests/swimlaneSort.test.cjs unit test
still passes.
2026-07-09 14:09:14 +03:00
github-actions[bot] e8939bc1e6 Bump versions for v9.82 2026-07-09 08:13:59 +00:00
Lauri Ojansivu bc8d58011c Prepare v9.82 release 2026-07-09 11:08:40 +03:00
Lauri Ojansivu 0bf8cae81c Updated ChangeLog. 2026-07-09 10:43:25 +03:00
Lauri Ojansivu 984ec160da Fix Playwright CI failures from #3624 and #4236
Two e2e tests failed across Chromium/Firefox/WebKit after this release's changes:

- 23-rest-api-more.e2e.js:209 (swimlanes: create, get, edit, delete): the
  #3624 change referenced models/lib/swimlaneSort via a function-scoped
  require('/models/lib/swimlaneSort'); the new module was not reliably bundled/
  resolved that way, so POST /api/boards/:id/swimlanes threw, was caught, and
  returned an error object with no _id — the test then read title off a null
  swimlane. Import nextSwimlaneSort at the module top level, matching every
  other import in server/models/swimlanes.js, so it is statically bundled.

- 02-cards-open-view.e2e.js:93 (editing the card title does not break the card):
  #4236 intentionally made plain Enter insert a newline in the card title
  (consistent with the description), with Ctrl/Cmd+Enter saving. The CardPage
  page object still saved with plain Enter, so the title never persisted and the
  read timed out. Save with Control+Enter (sends ctrlKey on every platform,
  which the save handler accepts).

Server-side Mocha, import regression, and Node E2E jobs already passed; this
makes the Playwright jobs green too.
2026-07-09 10:26:03 +03:00
Lauri Ojansivu a2df80e64d Updated ChangeLog. 2026-07-09 10:09:58 +03:00
Lauri Ojansivu 3aaf8f9b61 Fix #6444: RTL — typing a card title no longer garbles other lists' minicard titles
In an RTL language (e.g. Arabic) the board root is dir="rtl". Both the minicard
title (a dir="auto" .viewer) and the add-card composer textarea are dir="auto"
but had no bidirectional isolation, so they shared the surrounding bidi context.
Typing a strong RTL character into one list's composer re-resolved that shared
context and visibly reflowed the displayed minicard titles of the other lists
(no underlying data change — it reverted on refresh).

Each minicard title and the composer textarea now use `unicode-bidi: isolate`,
making them self-contained bidirectional runs that cannot perturb one another.
The isolation is locked in (positive + negative) by tests/minicardBidiIsolation.test.cjs;
full browser RTL behavior is covered by tests/playwright/specs/18-rtl-layout.e2e.js.

Thanks to xet7.
2026-07-09 10:09:32 +03:00
Lauri Ojansivu 1dfc223ca0 Updated translations 2026-07-09 09:48:44 +03:00
Lauri Ojansivu 604cc455e0 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu e85072a14a Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 1621f3784a Fix #5439: list scrollbar is now always visible on desktop and mobile browsers
List bodies used overflow-y:auto, so on platforms/browsers with overlay
scrollbars (macOS, iOS, Android, Firefox) the scrollbar auto-hid when idle and
there was no persistent affordance that a list is scrollable.

The list body now shows a scrollbar across all engines: overflow-y:scroll to
always reserve and show the track, ::-webkit-scrollbar width + thumb/track for
Chrome/Safari/Edge and mobile WebKit/Blink, scrollbar-width + scrollbar-color
for Firefox/Gecko (desktop and Android), and scrollbar-gutter:stable so content
does not shift. The cross-browser rules come from a Meteor-free builder
models/lib/scrollbarCss.js and are applied in client/components/lists/list.css;
both the builder contract and the applied CSS are unit tested (positive +
negative, including that no hide values sneak back in) in
tests/scrollbarCss.test.cjs.

Thanks to xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu ba6ad20a30 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 3847157753 Fix #3185: copying a card (or template) now copies its subtasks' checklists
When a card was copied — including generating a card from a template — each
subtask was inserted as a bare card document. The parent card's checklists were
copied, but the subtasks' checklists (and their items) were never copied, so
the new card's subtasks came out empty.

Card.copy() now copies each subtask's checklists (via Checklist.copy, which also
copies the checklist items) onto the newly inserted subtask. The subtask copy
document is built by a Meteor-free helper models/lib/subtaskCopy.js
(buildCopiedSubtaskFields — re-homes the subtask under the new parent and onto
the destination board; subtaskCopyChildKinds declares 'checklists' must be
reproduced), unit tested (positive + negative) in tests/subtaskCopy.test.cjs.

Thanks to ramses345 and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 4e77133aca Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 77c696e37b Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu e3f17be14d Fix #3624: new_swimlane REST API appends the swimlane last (and accepts a sort)
POST /api/boards/:boardId/swimlanes set the new swimlane's sort to
board.swimlanes().length (a count). When the existing swimlanes' sort values
were not a contiguous 0..n-1 run (after deletions/reordering, or swimlanes
created top-first with decreasing sorts), the count collided with or preceded
existing sorts and the new swimlane appeared FIRST instead of last.

The endpoint now computes the sort with nextSwimlaneSort(): max(existing sort)+1
for a reliable append (0 on an empty board), and honors an optional explicit
`sort` in the request body (the enhancement the reporter asked for). The pure
logic lives in models/lib/swimlaneSort.js, unit tested (positive + negative) in
tests/swimlaneSort.test.cjs.

Thanks to tamasberesoebb and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 5ca4ebe09a Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 3117e419bb Fix #3606: activity feed no longer shows "edited/deleted comment undefined"
The activity feed rendered "edited comment undefined" / "deleted comment
undefined" for comment edits and deletions. The template passed the comment's
ID into the "%s" of activity-editComment / activity-deleteComment: on older
activities the id was absent (literally "undefined"), and a deleted comment's
CardComment document is already removed so it cannot be looked up live either.

The edit and delete activities now store the comment text (commentText), and a
new Activities.commentDisplayText() helper picks the stored text, else the live
comment text (edit case), else an empty string — so the feed never renders the
word "undefined". The template shows the (sanitized) comment text. The
text-selection logic is a Meteor-free helper models/lib/commentActivity.js,
unit tested (positive + negative) in tests/commentActivity.test.cjs.

Thanks to janchuelo and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 3eba4aa782 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 127f1fb04e Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 14b4f5b5ed Fix #4249: filter by card title now works for renamed linked cards
Renaming a linked card (or linked-board card) wrote the new title only to the
linked target (via getRealId()/linkedId), leaving the linking card's OWN title
field stale. Filter-by-title queries each card document's own title field, so a
linked card silently dropped out of title filters after being renamed.

Card.setTitle() now also writes the linking card's own title (in addition to the
linked target), keeping it filterable while preserving the existing mirror
behavior. The set of documents to update is a Meteor-free helper
models/lib/linkedCardTitle.js (computeTitleUpdateTargets), unit tested
(positive + negative) in tests/linkedCardTitle.test.cjs.

Thanks to Ben0it-T and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu e6a4abd394 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 25cdc4db0d Fix #4494: Creating a board from a template no longer breaks subtasks
Creating a board from a template copies the template board. The copy inherited
the template's subtasksDefaultBoardId / dateSettingsDefaultBoardId verbatim.
When those pointed at the template board itself, subtasks created on the NEW
board were dropped onto the TEMPLATE board, and the subtask card linked back
across boards to the parent.

Board.copy() now repoints any subtasks/date-settings default that referenced
the source board to the new copy, and clears the paired list id so it self-heals
to a list on the new board. Defaults that deliberately point at another (shared)
board are left untouched. The remap logic is a Meteor-free helper
models/lib/boardCopyDefaults.js, unit tested (positive + negative) in
tests/boardCopyDefaults.test.cjs.

Thanks to Xilef11 and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu c062d93d57 Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu e014d5c816 Fix #4394: Register / Forgot Password links stay hidden after a failed login
Security: with "Self Registration" and "Forgot Password" disabled in the Admin
Panel, the Register (.at-signup-link) and Forgot Password (.at-pwd-link) links
were hidden only by a one-shot jQuery .hide() in userFormsLayout.onRendered.
When a login failed (notably the LDAP "User not Found" path), useraccounts
re-rendered the form and recreated those link nodes without the inline
display:none, so the links reappeared and exposed registration / password-reset
flows that were meant to be off.

The disable state is now also reflected as a class on the stable <body>
ancestor (registration-disabled / forgotpwd-disabled) with matching CSS in
userForm.css. Because the class sits on <body>, the rule re-hides every copy of
the links that useraccounts recreates after a failed login, instead of relying
on a single inline style that the re-render dropped.

Thanks to Alsterdetektive1 and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 84ab805ec1 Fix #4055: add ISO week-number regression test (already correct in current code)
#4055 reported the calendar/date-badge week number was one week too high
(Mon-Sat) and two too high (Sunday) for 2021-10-25..31 (ISO week 43). That was
the old moment-based calculation. The current native, DST-safe
dateUtils.getISOWeek() computes these correctly (verified across a DST timezone).
Adds tests/isoWeek.test.cjs pinning the exact reported dates so the off-by-one/
two cannot silently return.

Thanks to marcungeschikts and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu a9a5ac7a07 Fix #4236: Enter adds a new line in the card title (consistent with description)
The card title textarea submitted on plain Enter (only Shift+Enter made a new
line), while the card description textarea and the shared inlinedForm insert a
new line on plain Enter and only save on Ctrl/Cmd+Enter. So Enter could not add
a new line in the title, an inconsistent experience.

The title keydown handler now uses a shared, Meteor-free rule isSubmitKey()
(models/lib/editorSubmitKey.js): submit ONLY on Enter together with Ctrl or Cmd;
plain Enter and Shift+Enter fall through to the textarea default (a new line).
This matches the description field exactly. Unit tested (positive + negative) in
tests/editorSubmitKey.test.cjs.

Thanks to listenerri and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 0119e62da5 Fix #4653: LDAP username with a hyphen no longer becomes a dot
With LDAP_UTF8_NAMES_SLUGIFY enabled, sync.js slugified the username with
limax(text, { separator: '.' }) to transliterate UTF-8 names. limax replaces
every run of non-alphanumeric characters — including hyphens — with the
separator, so an LDAP username like "p.parta-partb" was turned into
"p.parta.partb". The generated Wekan username no longer matched and the user
could not log in.

slug() now slugifies each hyphen-separated segment on its own and rejoins
them with '-', so hyphens (and existing dots) survive while UTF-8 characters
in each segment are still transliterated. The hyphen-preserving logic is a
Meteor/limax-free helper packages/wekan-ldap/server/usernameSlug.js
(slugifyPreservingHyphens, with the transliterate function injected), unit
tested (positive + negative) in tests/ldapUsernameSlug.test.cjs.

Thanks to RowhamD and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 284d4b25ea Updated ChangeLog. 2026-07-09 09:45:53 +03:00
Lauri Ojansivu 75df523ef0 Fix #4946: Calendar week numbers now respect the defined start day of week
In the board Calendar view the week-number column was numbered from Sunday
regardless of the user's "start day of week" setting: setting Monday moved
the grid (via firstDay) but the week numbers still referred to Sunday-start
weeks. FullCalendar's default weekNumberCalculation is locale-based, so it
ignored firstDay.

The calendar now passes weekNumberCalculation that computes the number with
weekNumberByFirstDay(date, firstDay) from the same firstDay used to lay out
the grid, so the number matches the displayed week. The Meteor-free week math
lives in models/lib/weekStart.js and is unit tested (positive + negative,
including a case where Monday-start and Sunday-start must differ) in
tests/weekStart.test.cjs.

Thanks to helioguardabaxo and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu e5c844fca7 Fix #4881: Due this week / next week filter now respects the start day of week
The "Due this week" date filter selected the wrong week (it showed next
week's cards) and ignored the user's configured start day of week.

Root cause: relativeWeek() derived its window from startOf(now(), 'week'),
but the native dateUtils.startOf() has no 'week' case, so it returned the
date unchanged. The window therefore started at "today + startDayOfWeek"
(next week) instead of the beginning of the current week, and never took the
configured start weekday into account.

Fix:
- New Meteor-free helper models/lib/weekStart.js computes startOfWeek(),
  weekRange() and weekNumberByFirstDay() for an arbitrary start day of week
  (0=Sunday..6=Saturday, default Monday). Unit tested (positive + negative)
  in tests/weekStart.test.cjs, including a guard that "this week" must
  contain today.
- client/lib/filter.js relativeWeek() now uses weekRange(now(), weekStartDay,
  0|1) so "this week" and "next week" are the correct, non-overlapping
  Monday..Sunday (or configured) windows.
- The this/next-week buttons now toggle per week: clicking the active one
  clears it, clicking the other switches to it (the old code reset on either
  state, so you could not switch directly from this week to next week).

Thanks to mimZD and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 8a84707726 Fix #4978: Board background now updates when switching boards via favorites bar
Switching directly from one board to another using the favorites bar reused
the same boardBody template instance (currentBoard went A -> B, never null),
so boardBody.onRendered never fired again and the one-shot
Utils.setBackgroundImage() call never re-ran. The previous board's inline
background:url(...) on .board-wrapper stayed on screen. Switching via the
"all boards" list worked only because it destroys and re-creates boardBody.

Two-part fix:
- boardBody.onRendered now calls Utils.setBackgroundImage() inside a reactive
  this.autorun(), so it recomputes on every board switch (and background edit).
  It performs no DB writes, so there is no autorun write loop.
- setBackgroundImage() now clears any stale inline background when the new
  board has no image, instead of only ever setting one. The decision is a
  Meteor-free helper models/lib/boardBackground.js (computeBoardBackground),
  unit tested (positive + negative) in tests/boardBackground.test.cjs. Board
  color keeps being applied reactively via the .board-wrapper colorClass.

Thanks to dasarne and xet7.
2026-07-09 09:45:53 +03:00
Lauri Ojansivu 9f7eac158e Fix #5536: Automated rule can now move/link card to a different board
The rules move-card-to-top/bottom (on another board) and link-card-to-another-board
actions failed in two ways for boards the rule creator did not own:

- The action showed BLANK after creating the rule: the wizard inserted the
  trigger/action/rule with three optimistic client Collection.insert() calls
  that landed in minimongo limbo once the wizard subscription stopped (and the
  client allow/deny rules rejected them outright for non-owner members). These
  now create the rule through the server rules.createRule method, which keeps
  the action's own destination boardId.

- Execution crashed with a generic Internal Server Error: the destination
  swimlane fallback dereferenced ._id on a possibly-undefined swimlane titled
  exactly Default, which does not exist when the destination board's default
  swimlane was renamed, translated, or the board had no such swimlane.
  Resolution now uses the board's real default swimlane and a Meteor-free
  resolver (models/lib/ruleActionResolve.js) that returns empty string instead
  of throwing. The same guard is applied to the createCard and linkCard actions.
  Unit tested (positive + negative) in tests/ruleActionResolve.test.cjs.

Thanks to DarthKillian and xet7.
2026-07-09 09:45:53 +03:00
github-actions[bot] 9b729b502e Bump versions for v9.81 2026-07-09 00:17:50 +00:00
Lauri Ojansivu cef965f73f Updated ChangeLog. 2026-07-09 03:08:00 +03:00
Lauri Ojansivu 43dd98f8a1 Translate remaining English strings in fi.i18n.json to Finnish
Translate the strings that were still in English (mostly keys added this
release: the Rules Workflow trigger/action palette, the relative-date
unit selector, the custom color-wheel label, the Activities hide toggle
and the changed-description activity), using the terminology already used
in the existing Finnish translations.
2026-07-09 02:30:16 +03:00
Lauri Ojansivu 2cd772fcf5 Updated translations. 2026-07-09 02:25:49 +03:00
Lauri Ojansivu eaa1d823de Updated ChangeLog. 2026-07-09 02:19:52 +03:00
Lauri Ojansivu 1a41c72c21 Update CHANGELOG commit links to post-rebase hashes 2026-07-09 02:17:01 +03:00
Lauri Ojansivu 7943a17432 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu d41e17d6a0 Fix #5351: auto-add users to organizations matching their email domain on sign-up.
The Organization setting "Automatically add users with the domain name"
(org.orgAutoAddUsersWithDomainName) was never read at sign-up, so a user whose
email domain matched an organization was never added to it.

Add a pure helper models/lib/orgAutoAddByDomain.js (orgsToAutoAddForEmail) that
returns the ids of orgs whose configured domain exactly matches an email domain
(case-insensitive, exact — no subdomain, empty domain matches nothing), and wire
it into Accounts.onCreateUser so every non-admin sign-up path (password, LDAP,
invitation, new OIDC user) gains the matching org memberships using the same
{ orgId, orgDisplayName } shape used elsewhere, without duplicating existing
memberships. Add tests/orgAutoAddByDomain.test.cjs.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 4ec2cbcd5a Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 1ce2d8cc88 Fix #5369: Activities show/hide control is now a clear eye / eye-slash icon toggle.
Replace the counterintuitive unlabeled material toggle switch on the card
details Activities panel with an eye / eye-slash icon toggle: open eye means
activities are shown, crossed eye means they are hidden. Clicking flips the
state, the icon reflects the current visibility, and the tooltip states the
action (Show activities / Hide activities). The same eye pattern from the
login/register password-visibility toggle is reused for consistency.

For consistency the board sidebar Activities toggle (same showActivities
state) is switched from the check / empty-square icon to the same eye /
eye-slash icon toggle.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 8f76c7b2b0 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu b07c4d0a7b Feature #5394: sort the Link-card popup Cards dropdown alphabetically by title.
The "Link to this card" popup Cards pull-down previously listed cards in
board sort order, making a card hard to find on boards with many cards.
Sort it alphabetically by title (case-insensitive, locale/numeric aware)
via a small pure helper sortCardsByTitle, with unit tests. Thanks to xet7.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 5b43b7159e Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 7ce71dbb2e Feature #5396: edit Lists (title, color) via the REST API + api.py commands.
The REST endpoint PUT /api/boards/:boardId/lists/:listId already accepted
title, color, starred and wipLimit, but the color was stored unvalidated. It
now validates the color with normalizeListColor (a named palette color OR a
custom #rrggbb hex) and rejects an unknown color with a clear 400 instead of
silently storing None or hanging. The pure field/validation logic is extracted
to a Meteor-free helper models/lib/listApiUpdate.js so it is unit testable
without a running server (tests/listApiUpdate.test.cjs).

api.py gains two commands mirroring editcard/editcardcolor:
editlist BOARDID LISTID NEWLISTTITLE and
editlistcolor BOARDID LISTID COLOR, with usage lines in the help block.

Thanks to C0rn3j and xet7.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu d615e599f4 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu aad00cdba2 Fix #5442: outgoing webhooks now include the label name on add/remove label
Labels are embedded in the board document, not a separate collection, but the
Activities label() helper looked the label id up in the Cards collection and
always returned undefined. So the addedLabel/removedLabel webhook and
notification params never got a label value and the __label__ token in the
message text rendered as a bare, generic "label" with no name.

Resolve the label from the already-loaded board via getLabelById, and add a
pure labelDisplayName() helper (name -> color -> id fallback) so a nameless
label is shown by its color, matching the UI. Add a plain-Node unit test.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 07800da9af Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu da0d338e33 Fix #5482: adding/editing a card description fires the outgoing webhook
Card description changes did not create an activity, so no outgoing webhook
fired (unlike title/dueAt changes, whose Cards before.update hooks log an
activity). Log an a-changedDescription activity in the Cards before.update hook,
mirroring the title-change activity, so the existing Activities.after.insert
webhook hook fires. Fires on first-time set and later edits, not on no-op /
empty->empty saves. Pure descriptionChanged helper + tests, English i18n and
activity rendering.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu c3c091635d Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 8c499823cb Fix: the Rules Workflow view is now fully translatable via i18n.
The workflow trigger/action palette rendered hardcoded English labels
regardless of the UI language. Palette entries now carry an i18n labelKey
translated at render time via TAPi18n.__, reusing existing rule keys where
they fit and adding new r-w-* keys for workflow-only labels. Also routed
the slot clear tooltip through the existing r-remove key.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 1844425e8f Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 68dd14140f Fix: deleting a board rule no longer fails with "Access denied [403]"
Deleting a rule ran three separate client-side Collection.remove() calls
(Rules + Triggers + Actions). Each is gated by a per-collection allow() rule
that resolves the board from that document own boardId via
Boards.findOneAsync(doc.boardId). When a trigger/action document has no
resolvable boardId (legacy rules created before boardId was denormalised, or
docs not published to the client), the board resolves to null and
allowIsBoardAdmin returns false, so Meteor rejects the mutation with 403
"Access denied" and the delete fails even for a legitimate board admin.

Route rule deletion through a new server Meteor method rules.deleteRule, which
authorizes ONCE (active board admin or site admin) and removes the rule, its
trigger and its action server-side (bypassing the brittle client allow/deny).
The permission decision is extracted into the Meteor-free helper
models/lib/ruleDeletePermission.js and covered by
tests/ruleDeletePermission.test.cjs. No permission is loosened: only board
admins and site admins may delete, matching the board-admin gate used to
create a rule.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 1ba32f5269 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 30f36a7f31 Feature #5514: custom color-wheel (RGB/hex) picker with automatic readable text contrast.
Add a native color wheel (<input type="color">) alongside the existing fixed
named-color swatches in the color pickers for card labels, swimlanes, lists and
cards, so any #rrggbb color can be chosen. The schema color fields accept a
custom hex in addition to the named palette (via a validation that allows the
named values or a ^#[0-9a-fA-F]{6}$ hex), and normalizeListColor now passes hex
through, so existing named-color data keeps working. When a stored value is a
hex, elements apply an inline background-color instead of the card-details- /
swimlane- / list-header- / minicard- CSS class.

A new pure, Meteor-free helper models/lib/contrastColor.js computes a readable
text color from sRGB relative luminance (white on dark, black on light),
maps the named palette to hex, and validates/normalizes hex; it is applied as
an inline text color wherever a chosen color is a background behind text (label
chips, swimlane/list headers, minicard, card details header). Covered by
tests/contrastColor.test.cjs (20 assertions).
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 20b9c9ddd6 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 0e9aed5a4b Fix #5510: adding a board label via the REST API no longer errors/hangs
PUT /api/boards/:boardId/labels only wrote an HTTP response inside
`if (hasOwnProperty(req.body, "label"))` and read req.body.label.color/.name
unconditionally. Two consequences:

- A body without a `label` key fell through the `if` and NEVER sent a response,
  so the request hung until the client timed out (the reported "timeout").
- A body where `label` was a bare string (e.g. { "label": "labelName" }, the
  exact reported payload) produced name === undefined / color === undefined and
  pushed a schema-invalid label via Boards.direct, returning 200 for garbage.
- The catch returned { code: 200, data: error }, masking real failures as
  success (same anti-pattern already fixed for the board create/delete handlers).

The handler now returns a 404 for an unknown board, delegates input handling to
a new pure, Meteor-free helper models/lib/restLabel.js (buildBoardLabel), and
always sends a JSON response: 2xx for a well-formed create (documented contract
unchanged), 400 with a clear message for bad input, or the real error status in
the catch. buildBoardLabel accepts { label: { name, color } } (color defaulted
when absent, rejected when unknown) and, for convenience, a bare string taken as
the label name. Added tests/restLabel.test.cjs covering these cases.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 27d8468be0 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu d01526bbff Fix #5604: CSV export no longer crashes on boards with dangling references.
Exporter.buildCsv resolved each card list/swimlane/owner/member/assignee/label/
customField by id and read a property directly on the result of Array.find(),
which is undefined when the referenced document was deleted. Large/old boards
often contain such dangling references, so the read threw "Cannot read property
title of undefined" and crashed the whole CSV export request. Extracted the
per-card row builder into a pure, null-safe helper models/lib/exporterCsvRow.js
(unit-testable without Meteor) that emits a blank cell for a missing reference
while keeping identical output for well-formed cards. Added
tests/exporterCsvRow.test.cjs.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu ab3b48b334 Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu b0da9097c1 Fix #5656: Calendar view now honors the active board filters.
The Calendar view queried cards with only a board+date selector via
Board.cardsInInterval()/cardsDueInBetween() and ignored the active board
Filter (member / assignee / due-date / label / custom-field), so it showed
every card in the interval regardless of the sidebar filter, unlike the
Board / Lists / Swimlanes views.

Extract the calendar selector building into a pure, Meteor-free helper
(models/lib/calendarFilter.js) that ANDs the Filter selector into the
date-interval selector via combineWithFilter, so the Filter is always applied
and can never collide with the interval selectors top-level $or. The calendar
events() callback now passes Filter._getMongoSelector() when Filter.isActive(),
and the onRendered autorun reads Filter.isActive() so events are refetched when
the filter changes. Add tests/calendarFilter.test.cjs.

Thanks to kerier and xet7.
2026-07-09 02:14:07 +03:00
Lauri Ojansivu 158130cbfc Updated ChangeLog. 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 53328f40c6 Add unit selector (minute/hour/day/week/month) to the Rules relative-date action (#5621) 2026-07-09 02:14:07 +03:00
Lauri Ojansivu e7eeaf0b10 Add 'Count amount of tests by category' menu option to rebuild-wekan scripts 2026-07-09 02:14:07 +03:00
Lauri Ojansivu 609b07a8db Add 'Count amount of tests by category' menu option to rebuild-wekan scripts 2026-07-09 00:02:17 +03:00
Lauri Ojansivu 7545bb5506 Add #6442 fix to Upcoming WeKan release CHANGELOG section 2026-07-08 23:58:21 +03:00
Lauri Ojansivu 457713968c Fix #6442: All Boards custom drag order — drop now persists the reorder
Follow-up to #6439, which restored the drag preview but left the drop a
no-op: dragging a board on the All Boards page in "Custom (drag order)"
mode showed the dashed-border preview, but releasing it snapped the board
back without reordering.

The drop handler built the current on-screen order from
`el.classList[0]` for each `.js-board`. But the board list item is
`li.js-board(class="{{_id}} …")`, and Jade emits the literal `js-board`
class FIRST, so classList[0] is the string "js-board" for every board —
never the board _id. The ordered ids were therefore
['js-board','js-board',…]; computeReorderedSortIndex could not find the
dragged/target ids in that array, returned null, and nothing was written
to profile.boardSortIndex. The preview still worked because it is driven
by the dragover CSS class, independent of the id.

Read each board's _id from its Blaze data context (the same source
dragstart already uses via `this._id`) instead of the literal class, so
the real display order reaches the (already correct, unit-tested) reorder
helper and the drop persists. Add a regression guard in
tests/boardSortReorder.test.cjs asserting the wrong-class extraction
("js-board" repeated) yields no mapping while real ids reorder.
2026-07-08 23:57:39 +03:00
Lauri Ojansivu 372a58c112 Updated ChangeLog. 2026-07-08 23:49:32 +03:00
Lauri Ojansivu 527962966b Add Upcoming WeKan release CHANGELOG section for #6443 fix
Link the fix commit under a new "Upcoming WeKan release" heading, in the
same style as previous release entries.
2026-07-08 23:44:10 +03:00
Lauri Ojansivu a37e7d1cc7 Fix #6443: cards on a deleted swimlane are invisible in swimlane view
On old boards a swimlane can be deleted while its cards keep the now
dangling swimlaneId (an "orphaned" card). Cards with no swimlane at all
(null / '' / missing) already surface in every swimlane, but an orphaned
card matched no existing swimlane and so appeared in NO swimlane in
swimlane view, while still showing in list view (which applies no
swimlane scope) — the reported "no swimlane" symptom where some boards
show no cards in swimlane mode but work in list mode.

Mirror the existing orphaned-LIST fallback (Swimlanes.orphanedSwimlaneLists,
which surfaces orphaned lists in the first swimlane) for cards: when a
list's cards are fetched for the board's FIRST swimlane, build the
swimlane-membership clause as a single { swimlaneId: { $nin:
<otherSwimlaneIds> } } (everything not owned by another existing
swimlane: own id, null/'', missing, or orphaned). This keeps it a single
field clause with no second $or, so the #6441 board-wide label filter
still holds. Orphaned cards therefore appear once, in the first swimlane,
without a database migration.

Threaded through the pure selector helpers (swimlaneMembershipSelector /
listCardsSelector / filteredListCardsSelector) and the in-memory sibling
filterCardsByListAndSwimlane, a new List.orphanedCardsSwimlaneIds helper,
and the cards()/cardsUnfiltered()/allCards() model methods plus the
listBody cardsWithLimit render helper. Covered by new cases in
tests/swimlaneFilter.test.cjs (18 assertions pass; #6441 guards intact).
2026-07-08 23:43:26 +03:00
Lauri Ojansivu 33ad40d012 Updated translations. 2026-07-08 23:12:53 +03:00
Lauri Ojansivu 44b5e12de9 Update README to reflect Meteor version change 2026-07-06 19:07:03 +03:00
github-actions[bot] 1207b573b5 Bump versions for v9.80 2026-07-06 15:40:14 +00:00
Lauri Ojansivu a26295a2fb Updated ChangeLog. 2026-07-06 18:35:50 +03:00
Lauri Ojansivu b2da9ac8d4 Admin Panel Domains table: server-side pagination, column sort and search (like Board Table view). Thanks to xet7. 2026-07-06 18:27:38 +03:00
Lauri Ojansivu 53dcf7379d Updated ChangeLog. 2026-07-06 18:13:31 +03:00
Lauri Ojansivu dfa6c78d4f Add regression test for board-invite email language. Refs #5664. 2026-07-06 16:02:56 +03:00
Lauri Ojansivu 795850ed05 Updated ChangeLog. 2026-07-06 15:25:54 +03:00
Lauri Ojansivu 9ef7f4a07a Thanks to 32Dexter and xet7 . Fixes #5666 2026-07-06 15:22:22 +03:00
Lauri Ojansivu cb2394a95d Updated ChangeLog. 2026-07-06 15:12:21 +03:00
Lauri Ojansivu ac368de06a Thanks to MarcusDger and xet7 . Fixes #5667 2026-07-06 15:12:21 +03:00
github-actions[bot] b26494766f Bump versions for v9.79 2026-07-06 11:46:51 +00:00
Lauri Ojansivu 68ab63c7fe Updated ChangeLog. 2026-07-06 14:43:27 +03:00
Lauri Ojansivu e3e71b254a Updated ChangeLog.
Link #6439/#6440/#6441 and the i18n test fix to their commits; wire the new unit tests into test:unit:node.

Thanks to jullbo and xet7.
2026-07-06 14:35:35 +03:00
Lauri Ojansivu fc4eadedc9 Fix flaky server-side Mocha i18n test (zh-CN "is not a spy")
The .loadLanguage suite intermittently reported "1 failing" (the failure bouncing between the zh-CN and fr cases). Two causes: stubbing the shared TAPi18n.i18n let state leak between tests, and asserting via sinon-chai routed the matcher through chai-as-promised so it could evaluate after afterEach's sinon.restore(). Give each test a fresh i18next instance via TAPi18n.init() (mirrors the reliable .setLanguage suite) and assert with synchronous sinon.assert.* instead of sinon-chai.

Thanks to xet7.
2026-07-06 14:35:34 +03:00
Lauri Ojansivu 9dca403782 Fix #6441: label filter now applies board-wide across all swimlanes
Thanks to jullbo and xet7.
2026-07-06 13:49:44 +03:00
Lauri Ojansivu dd90995db7 Fix #6440: '+' add-item button on minicard checklist now adds the item
Thanks to jullbo and xet7.
2026-07-06 13:49:44 +03:00
Lauri Ojansivu f8e31745d8 Fix #6439: Custom (drag order) sort on All Boards page now reorders via drag-and-drop
Thanks to jullbo and xet7.
2026-07-06 13:49:44 +03:00
github-actions[bot] 736d308860 Bump versions for v9.78 2026-07-06 09:03:57 +00:00
Lauri Ojansivu 9eca7a3941 Updated ChangeLog. 2026-07-06 11:54:17 +03:00
Lauri Ojansivu af436e16c4 Updated ChangeLog. 2026-07-06 11:52:49 +03:00
Lauri Ojansivu b1b414e850 - **Fix "Internal Server Error" when signing up despite the account being created**:
Registering a new account showed a red "Internal server error" on the sign-up form even
  though the account was created and could sign in — which typically happens when SMTP is not
  configured. Root cause: useraccounts' `ATCreateUserServer` creates the account and then
  calls `Accounts.sendVerificationEmail()` (because `sendVerificationEmail: true`); when SMTP
  is missing/misconfigured that send throws **after** the user row is inserted, and with no
  try/catch the exception leaves the createUser method as an opaque HTTP 500. The verification
  email is best-effort at sign-up, so `Accounts.sendVerificationEmail` is now wrapped to log
  and swallow a transport failure — registration completes and redirects to sign-in — while an
  "already verified" error is re-thrown so the resend-verification flow still reports it. This
  mirrors the [#5706](https://github.com/wekan/wekan/issues/5706) reset-password hardening.
  Covered by `tests/verificationEmail.test.cjs`.

Thanks to Firas-Git and xet7.

Fixes #5672
2026-07-06 11:50:28 +03:00
Lauri Ojansivu 57da3885a2 Updated ChangeLog. 2026-07-06 11:38:57 +03:00
Lauri Ojansivu cb9c897309 - **Fix can not add members to a Linked Card**:
A **linked card** (created by "Link card to this card") is only a placeholder on the board
  that links it — its members are stored on the **real card** it points at (`linkedId`), which
  lives on another board, and `Card.getMembers()` / `assignMember()` / `unassignMember()`
  already read and write that real card. But the member **picker** listed the members of the
  board you were *viewing* the linked card on, not the board the real card lives on. So on a
  board that links a card from another board, the picker offered the wrong set of members and
  toggling them did not behave as a consistent add/remove — "can not add members to the linked
  card". The picker now resolves a linked card to its real card's board and offers *that*
  board's active members (matching where the membership is actually stored), falling back to
  the current board for normal cards or when the real card isn't loaded yet. Extracted the
  target-card/target-board resolution into `models/lib/linkedCardMembers.js`, covered by
  `tests/linkedCardMembers.test.cjs`.

Thanks to ITT5 and xet7 !

Fixes #5676
2026-07-06 11:35:56 +03:00
Lauri Ojansivu 81f07baeb8 Updated ChangeLog. 2026-07-06 11:21:41 +03:00
Lauri Ojansivu 001c258f96 - **Fix can't search numbers in custom fields**:
Searching a board for the value of a **number** or **currency** custom field (e.g. a
  transaction number `2025001`, or a currency amount `123`) found nothing. Those field types
  store their value as a JS **Number** (the inputs save `parseInt(...)` / `Number(...)`), but
  `Board.searchCards()` only matched custom fields with `{ value: <regex> }` — and a MongoDB /
  Minimongo regex only matches **string** values, so it silently skipped every numeric custom
  field. The search now also adds an exact numeric-equality clause when the term is a plain
  number (a comma is accepted as a decimal separator, matching the currency input), so numeric
  custom fields match too; text/title/description matching is unchanged. Card search runs
  against Minimongo on the client, which — like MongoDB — cannot regex a numeric field, so
  equality is the correct cross-environment match. Covered by `tests/cardSearch.test.cjs`.

Thanks to MarcusDger and xet7.

Fixes #5680
2026-07-06 11:19:15 +03:00
Lauri Ojansivu 9d77b9d7b7 Updated ChangeLog. 2026-07-06 10:57:03 +03:00
Lauri Ojansivu 2242da4176 - **Fix "Removed nonexistent document" crash during notification_cleanup**:
The scheduled notification-tray cleanup logged `Exception in removed observeChanges
  callback: Error: Removed nonexistent document …`. The crash itself came from the old
  `cottz:publish-relations` package (since replaced by `reywood:publish-composite`), but the
  cleanup that provoked it still fired one **un-awaited** `removeNotification()` per expired
  notification — a separate `Users.update` `$pull` each time — so a user with K stale
  notifications produced K writes to the Users collection, and every publication that
  republishes user documents re-ran its observers K times in quick bursts (the churn that
  surfaced the removed-document error), while any rejected write went unhandled. The cleanup
  now scans only users that have notifications and prunes each user's stale notifications in a
  single awaited `$pull … $in`. It also removes an activity's notifications only when *every*
  entry for that activity is read and past its removal age (so a freshly re-created unread
  notification sharing an activity id is not dropped), and guards missing/invalid `read`
  timestamps. Covered by `tests/notificationCleanup.test.cjs`.

Thanks to xet7.

Fixes #5685
2026-07-06 10:55:29 +03:00
Lauri Ojansivu 0a3a28ef29 Updated ChangeLog. 2026-07-06 10:46:27 +03:00
Lauri Ojansivu dcf5aa65b0 - **Fix impossible to select another board in rules**:
In the IFTTT-Rules "Move card to the board" and "Link card to the board" actions, the
  board dropdown was empty for some users — the current board included — while colleagues
  in the same company could pick boards normally. Root cause: the dropdown filtered the
  (already access-scoped) client cache with `'members.userId': me`, i.e. it only kept boards
  where the user has a *direct* member entry. A user who reaches a board through an
  Organization, Team or email-domain share — but is not listed individually in
  `board.members` — matched nothing, so the whole selector came up empty. The dropdown now
  filters by the same visibility rule as `Boards.userBoards()` (public OR active member OR
  active org OR active team OR active domain), so org/team/domain-shared boards appear too,
  while archived boards, template containers, the user's templates board and internal helper
  boards stay excluded.

Thanks to Augustin356 and xet7 !

Fixes #5698
2026-07-06 10:43:26 +03:00
Lauri Ojansivu dd3be179ca Updated ChangeLog. 2026-07-06 10:33:15 +03:00
Lauri Ojansivu bc1f149ea3 - **Fix board disappeared after adding another user**:
A board admin adding another user could make the whole board silently vanish from a
  user's board list, with no archive and "nothing out of order" in the logs. Root cause:
  the `setBoardTeams` server method (used by the board Teams/Members management popups)
  blindly overwrote the board's entire `members` array with a snapshot sent by the client.
  When that client's board document was stale — e.g. a member had just been added via
  `inviteUserToBoard` on the server and the change had not yet propagated to the client —
  the overwrite dropped members, and in the worst case the board's own admin, so the board
  no longer matched the board-list publication (which requires an active membership) and
  disappeared. Because a wholesale `$set: { members }` never passes through
  `foreachRemovedMember()`, no `removeBoardMember` activity was logged and no
  card/watcher/star cleanup ran, which is why the logs looked normal. `setBoardTeams` now
  reconciles against the authoritative server-side members instead of trusting the client
  snapshot: it never drops an active admin, keeps every existing member the client still
  lists, adds the members the client introduces, and only removes non-admin members the
  client explicitly omitted (an intentional team-leave) — logging and cleaning up each such
  removal so it is auditable rather than silent.

Thanks to DVNBLMHC and xet7 !

Fixes #5730
2026-07-06 10:33:15 +03:00
github-actions[bot] 0dd11ea573 Bump versions for v9.77 2026-07-06 00:01:08 +00:00
Lauri Ojansivu 5feb83d919 Updated ChangeLog. 2026-07-06 02:51:25 +03:00
Lauri Ojansivu 8980668686 Updated ChangeLog. 2026-07-06 02:47:52 +03:00
Lauri Ojansivu 0d4e48ddd6 Retry snapcraft install in the release-all.yml snap job
The Snap Store intermittently rejects `sudo snap install snapcraft --classic`
with "error: cannot install snapcraft: too many requests" (a 429-style rate
limit) that instantly failed the whole snap job before any build ran. Retry up
to 5 times with 30s backoff so one transient store throttle no longer fails the
release, matching the remote-build retry already in the same job.
2026-07-06 02:47:32 +03:00
Lauri Ojansivu a01b175895 Updated ChangeLog. 2026-07-06 02:37:29 +03:00
Lauri Ojansivu 83f63c9efa Updated Code of Conduct.
Thanks to xet7 !
2026-07-06 02:37:28 +03:00
Lauri Ojansivu 3b031bd901 Updated Code of Conduct.
Thanks to xet7 !
2026-07-06 02:37:28 +03:00
github-actions[bot] e2a7e300c9 Bump versions for v9.76 2026-07-05 23:07:35 +00:00
Lauri Ojansivu 844f59e12e Updated ChangeLog. 2026-07-06 01:57:54 +03:00
Lauri Ojansivu 1a3d7b5da4 Updated translations. 2026-07-06 01:57:00 +03:00
Lauri Ojansivu 3d2bdf4f22 Updated ChangeLog. 2026-07-06 01:50:59 +03:00
Lauri Ojansivu b9fe1f1639 Updated ChangeLog. 2026-07-06 01:35:30 +03:00
Lauri Ojansivu a39a7b90b1 Updated ChangeLog.
Repoint the #6427 upcoming entry to its on-main commit (the old link was
malformed and pointed at an orphaned pre-rebase commit).
2026-07-06 01:28:24 +03:00
Lauri Ojansivu 5d1c979e3b Updated ChangeLog.
Link the #5808 and board-not-found-flicker upcoming entries to their commits.
2026-07-06 01:26:43 +03:00
Lauri Ojansivu d00dc6056b Fix the "Board not found" flicker (stale-while-revalidate for the client board cache)
The client board cache (imports/lib/dataCache.js) re-fetches its value inside a
reactive computation. When the board doc is briefly absent from minimongo (a
subscription stops and restarts, momentarily removing the doc), the empty value
was surfaced immediately, flashing the "Board not found" shell — and on WebKit
throwing a Blaze "Can't select in removed DomRange" error tearing down the card
view. It self-recovers when the doc is re-delivered, so it presents as a flicker,
reliably hit only on Firefox/WebKit timing (which is why it surfaced as
browser-specific Playwright failures in 14-voting-watchers and 24-feature-issues).

Fixed with an opt-in stale-while-revalidate mode on DataCache, enabled only for
getBoard: a transient miss over an already-cached board keeps the last value and
re-checks after a short delay, surfacing an empty result only if the board is
still gone then (a genuine deletion / access loss). First-ever loads and caches
that did not opt in are unchanged. Core decision extracted to the pure
shouldDeferCacheMiss helper with unit tests.

Also hardens the related Playwright specs: a retry in CardPage.openActionsMenu, a
longer visibility budget for the WebKit slow card-render in spec 16, and removes a
reload race in the move-list-right spec (moveListBy fires two un-awaited
updateListSort calls, so an immediate reload read a half-applied order).
2026-07-06 01:26:05 +03:00
Lauri Ojansivu 878a24f586 Fix #5808: linking a card to another linked card made both cards inaccessible
The "Link to this card" target picker only excluded template cards, so an
existing linked card (or a card that links back to the current board) could be
chosen as a link target. That builds a chain of linkedId pointers, but the card
helpers (getTitle/getBoardTitle/getRealId) resolve linkedId only one hop, so
such a card renders as an empty/broken pointer and becomes inaccessible (the
reported freeze). As the reporter suggested, prevent the configuration: only a
real card — not a linked card/linked board, not one of the linking board's own
cards, and not a card that links back to one of them — may be a link target.
Enforced in the picker query and re-checked at creation time via the pure
isLinkableCardTarget guard, mirroring the #3328 parent/subtask cycle guard.
2026-07-06 01:25:39 +03:00
Lauri Ojansivu 6d08181edd Updated ChangeLog. 2026-07-05 23:24:30 +03:00
Lauri Ojansivu d5596f05b7 - **Fix DEFAULT_AUTHENTICATION_METHOD env var ignored, and Admin Panel Layout save hanging.
Two related problems with the default login authentication method.
  - **Env var ignored:** the stored setting was only ever seeded as `password` and
    `DEFAULT_AUTHENTICATION_METHOD` was never applied, so operators setting it (e.g. Kubernetes/Helm
    `DEFAULT_AUTHENTICATION_METHOD: ldap`) saw no effect. Startup now applies the env var
    authoritatively: it seeds the value on a fresh install and, on existing installs, keeps the
    stored `defaultAuthenticationMethod` in sync with the env var on every boot (the operator's env
    is the source of truth), so the method can be configured entirely by env without the Admin Panel.
    The value is normalized (trimmed + lower-cased), so `DEFAULT_AUTHENTICATION_METHOD=LDAP` works.
  - **Layout save hanging / not persisting:** the authentication-method `<select>` is populated by an
    async `Meteor.call`, so clicking **Admin Panel > Layout > Save** before it loaded sent an empty
    value for the **required** `defaultAuthenticationMethod` field, which silently failed validation —
    the save looked stuck and nothing changed. The save now falls back to the currently stored method
    when the select is empty, so a real value is never overwritten by `''`.
  Both paths share one pure helper (`resolveDefaultAuthenticationMethod`) that never resolves to an
  empty string.

Thanks to joe-speedboat and xet7 !

Fixes #5879
2026-07-05 23:22:16 +03:00
Lauri Ojansivu 22b280a18e Updated ChangeLog. 2026-07-05 23:13:36 +03:00
Lauri Ojansivu 2e7c4ede2a - **[Reduce card flicker on drag by only writing changed fields on a card move](https://github.com/wekan/wekan/issues/6430)** (#6430):
`Card.move()` wrote `boardId`/`swimlaneId`/`listId` into the update unconditionally, even for a
  same-board drag to another list (the reported case). Keeping `boardId` in the `$set` on every drag
  re-ran the `boardId`-gated `Cards.after.update` hook that re-syncs the card's checklists and
  checklist items via `multi` updates, ran the cross-board consistency guard and the
  `denyCrossBoardMove` deny-rule DB lookup, and invalidated more reactive dependents than
  necessary — server work and reactivity churn that contributed to a ~1s card flicker on large
  boards. `Card.move()` now writes **only the fields that actually change** (via the pure
  `computeCardMoveModifier` helper) and skips the write entirely when a card is dropped back in the
  same place, so a same-board move no longer touches boardId or the cross-board hooks. The
  `moveCard`/`moveCardBoard` activity generators already re-check `doc.X !== oldX`, so trimming the
  modifier does not drop any activity. Note: this reduces the drag-time work behind the flicker; the
  residual reactive re-render cost on very large boards is a separate performance topic.

Thanks to mimZD and xet7 !

Fixes #6430
2026-07-05 23:12:16 +03:00
Lauri Ojansivu ed77abd90d Updated ChangeLog. 2026-07-05 22:56:01 +03:00
Lauri Ojansivu 45fb547947 - **Fix thousands of unsolicited empty "Default" swimlanes created on some boards**:
`Board.getDefaultSwimline()`/`getDefaultSwimlineAsync()` self-heal a missing default swimlane by
  reading the board's swimlanes and inserting one if none exist. That check-then-insert is a race:
  concurrent or repeated server calls for a swimlane-less board each saw zero swimlanes and each
  inserted a new one, so some boards accumulated 30 000+ empty "Default" swimlanes and became
  unloadable (the `key 'default (en)' returned an object instead of string` log is a harmless i18n
  side effect — the title correctly falls back to the string `Default`). Follow-up to the
  client-side [#6382](https://github.com/wekan/wekan/issues/6382) fix, which only stopped the
  browser from auto-creating them. Fixed by making the server self-heal **idempotent**: the default
  swimlane is now upserted with a deterministic `_id` (`<boardId>-default`), so the `_id` unique
  index guarantees at most one default swimlane per board no matter how many times, or how
  concurrently, the getters run. `archived`/`type` are set explicitly in the `$setOnInsert` because
  their schema autoValue/defaultValue only fire on insert, not upsert. Note: this prevents new
  duplicates; boards that already accumulated thousands still need a one-off cleanup.

Thanks to brlin-tw and xet7 !

Fixes #6429
2026-07-05 22:54:13 +03:00
Lauri Ojansivu 477ad737d3 Updated ChangeLog. 2026-07-05 22:30:37 +03:00
Lauri Ojansivu a2802a850b - **Release workflow: publish the Helm chart only after the Docker image is pushed**:
In `.github/workflows/release-all.yml` the `charts` job now depends on the `docker` job
  (`needs: docker`) instead of running in parallel right after `bump`. GitHub Actions runs a job
  only when all of its `needs` jobs succeed, so the wekan/charts Helm chart is published only after
  the multi-arch image is live on Docker Hub / Quay.io / GHCR (and is skipped if `docker` fails).
  This prevents ArtifactHub from scanning a freshly published chart whose image tag does not exist
  yet and emailing the maintainer about the missing Docker image.

Thanks to xet7 !
2026-07-05 22:29:23 +03:00
Lauri Ojansivu 5cbe27be7e Updated ChangeLog. 2026-07-05 22:23:36 +03:00
Lauri Ojansivu 8b21158736 - **Fix [#6427](https://github.com/wekan/wekan/issues/6427): notification emails linked to
`/b/undefined/board/<cardId>` instead of the real board.** On the server
  `ReactiveCache.getBoard()` is async and returns a Promise, but `Cards.board()` did not await it,
  so the synchronous `Card.originRelativeUrl()`/`absoluteUrl()` interpolated a Promise —
  `board._id` and `board.slug` were `undefined`, producing `/b/undefined/board/<cardId>` in
  card activity notification emails (the client UI was unaffected because `this.board()` is
  synchronous there). Fixed by making `Card.originRelativeUrl(board)`/`absoluteUrl(board)` accept an
  already-resolved board and fall back to `this.boardId` (always available synchronously) when the
  board is a Promise, and by passing the awaited board from `server/models/activities.js` so the
  correct board id and slug are used.

Thanks to titver968 and xet7 !

Fixes #6427
2026-07-05 22:23:36 +03:00
github-actions[bot] 651c6f8fb6 Bump versions for v9.75 2026-07-05 19:05:05 +00:00
Lauri Ojansivu 5b67312f0f Updated ChangeLog. 2026-07-05 21:55:15 +03:00
Lauri Ojansivu 1a222c4477 - **[ScannerBleed](https://github.com/wekan/wekan/security/advisories/GHSA-x3xm-pxrv-jg7p)
— shell injection (RCE) via a malicious upload filename in the external antivirus scanner command path**
  ([GHSA-x3xm-pxrv-jg7p](https://github.com/wekan/wekan/security/advisories/GHSA-x3xm-pxrv-jg7p),
  CWE-78 OS Command Injection). Same RCE class as
  [AvatarBleed](https://wekan.fi/hall-of-fame/avatarbleed/) (CVE-2026-52891, GHSA-35j7-h385-2q9g)
  and its follow-up regression (CVE-2026-53447 / GHSA-qfqv-42qw-vvwh area), but in a code path that
  was **never** covered by those fixes.
  In `models/fileValidation.js`, when an admin has configured an external scanner (antivirus)
  command line with a `{file}` placeholder, the uploaded file path was interpolated into the command
  and run through `asyncExec` (`promisify(exec)`), which spawns `/bin/sh -c` and interprets **all**
  shell metacharacters:
  ```js
  await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"'));
  ```
  Wrapping the path in double quotes is not a shell boundary — inside double quotes the shell still
  expands `$(...)`, backticks and `\`, so a filename such as `` a`id`.png `` or `$(touch /tmp/pwn).png`
  escaped the argument and executed as the Wekan server process. Any **authenticated** user who can
  upload an attachment could trigger it, on servers that have an external scanner configured. Unlike
  the sibling MIME-detection path (`detectMimeFromFile`, which already uses `execFile` with no shell)
  and unlike the AvatarBleed fix (which strips non-alphanumeric characters from the filename), this
  scanner path had **zero** sanitization.
  - **Fixed** by POSIX single-quote-escaping the interpolated file path via a new `shellQuote()`
    helper (wrap in single quotes, escape embedded `'` as `'\''`). Inside single quotes the shell
    interprets no metacharacters, so a malicious filename can no longer break out of the argument or
    inject commands, while the admin's arbitrary command line and the exact on-disk path are both
    preserved. CVSS:3.1 9.9 Critical (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H).
  - Affected Wekan v9.06 and earlier through the current release; fixed at the upcoming WeKan release.
    Reported by **DavidCarliez**.

Thanks to DavidCarliez and xet7 !
2026-07-05 21:55:15 +03:00
860 changed files with 167870 additions and 87267 deletions

No files matched your search

-2
View File
@@ -1,5 +1,3 @@
version: '3.7'
services:
wekandb-dev:
+107
View File
@@ -0,0 +1,107 @@
name: Build meteor-spk bundle
# Manually triggered. Assembles WeKan's modernized meteor-spk (the Sandstorm packaging
# tool + its meteor-spk.deps: Node 24, FerretDB, the Mongo 3.x CLIs and the modern
# sandstorm-http-bridge) and zips it as meteor-spk-YYYY-MM-DD_HH_MM_SS.zip — the same kind
# of self-contained meteor-spk bundle that https://github.com/sandstorm-io/meteor-spk
# releases, but with WeKan's Node 24 / FerretDB deps baked in. Download it to `meteor-spk
# pack` WeKan (or any Meteor 3.x app) without re-running build-deps.sh each time.
#
# gh workflow run meteor-spk.yml
#
# This only assembles + zips the tool; it does NOT pack or sign a WeKan .spk, so it needs
# no SANDSTORM_KEYRING. The .spk itself is built by sandstorm.yml; the Sandstorm App
# Market publish is done by sandstorm-store.yml.
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch, tag or SHA to build the meteor-spk deps from (default: main)'
required: false
default: 'main'
permissions:
contents: write
env:
NODE_VERSION: '24'
jobs:
meteor-spk:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.ref || 'main' }}
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
# Same as sandstorm.yml: Ubuntu 24.04's AppArmor blocks the unprivileged user
# namespaces the Sandstorm install relies on; relax it so the install (whose
# sandstorm-http-bridge build-deps.sh copies in) can run on the runner.
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git zip
- name: Install Sandstorm (dev, unattended)
# build-deps.sh's [bridge] step copies the modern
# /opt/sandstorm/latest/bin/sandstorm-http-bridge into the deps, so Sandstorm
# must be installed first — same ordering as sandstorm.yml.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (Node 24 + FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io; build-deps.sh swaps in
# Node 24, FerretDB, the Mongo 3.x CLIs, the migration launcher/importer, keeps
# niscud, and swaps in the modern sandstorm-http-bridge. Produces
# $HOME/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
run: bash sandstorm-src/build-deps.sh
- name: Zip the meteor-spk bundle
id: zip
run: |
set -euo pipefail
STAMP="$(date +'%Y-%m-%d_%H_%M_%S')"
ZIP="meteor-spk-${STAMP}.zip"
BUNDLE_DIR="$HOME/projects/meteor-spk"
if [ ! -d "$BUNDLE_DIR/meteor-spk-0.6.0" ]; then
echo "::error::Expected meteor-spk bundle at $BUNDLE_DIR/meteor-spk-0.6.0 was not produced by build-deps.sh."
exit 1
fi
# Zip so it extracts to a top-level meteor-spk-0.6.0/ directory, like the
# upstream meteor-spk release archives. Exclude the base tarball build-deps.sh
# downloaded next to it.
( cd "$BUNDLE_DIR" && zip -r -y -q "$GITHUB_WORKSPACE/$ZIP" meteor-spk-0.6.0 -x '*.tar.xz' )
ZIP_BYTES=$(stat -c%s "$GITHUB_WORKSPACE/$ZIP")
echo "Packed $ZIP: $((ZIP_BYTES/1024/1024)) MiB ($ZIP_BYTES bytes)"
ls -la "$GITHUB_WORKSPACE/$ZIP"
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.zip.outputs.zip }}
path: ${{ steps.zip.outputs.zip }}
retention-days: 7
- name: Upload the meteor-spk bundle to the newest WeKan release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
echo "::warning::No existing GitHub Release found; the meteor-spk bundle is available only as a workflow artifact."
exit 0
fi
echo "Uploading ${{ steps.zip.outputs.zip }} to the newest release: $TAG"
gh release upload "$TAG" "${{ steps.zip.outputs.zip }}" --clobber
File renamed without changes.
File diff suppressed because it is too large. Load diff
+145
View File
@@ -0,0 +1,145 @@
name: Publish .spk to Sandstorm App Market
# Manually triggered. Takes the Sandstorm .spk that sandstorm.yml already built and
# attached to a WeKan GitHub Release, renames it to wekan-<VERSION>.spk (VERSION is the
# WeKan release version, e.g. 9.88), and publishes it to the Sandstorm App Market with
# spk publish wekan-<VERSION>.spk
# exactly like releases/release-sandstorm.sh does locally.
#
# gh workflow run sandstorm-store.yml
# gh workflow run sandstorm-store.yml -f version=9.88
# gh workflow run sandstorm-store.yml -f version=9.88 -f tag=v9.88
#
# Needs SANDSTORM_KEYRING (base64 of ~/.sandstorm-keyring) — `spk publish` proves app
# ownership with the WeKan Sandstorm app's private key from that keyring — and installs
# Sandstorm + assembles meteor-spk the same way sandstorm.yml does, so `spk` is present.
# It does NOT rebuild the .spk; it publishes the one already on the release.
on:
workflow_dispatch:
inputs:
version:
description: 'WeKan version for the store filename wekan-<VERSION>.spk (e.g. 9.88). Default: derived from the release tag.'
required: false
default: ''
tag:
description: 'Release tag whose .spk to publish (default: newest release).'
required: false
default: ''
permissions:
contents: read
env:
NODE_VERSION: '24'
jobs:
sandstorm-store:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git
- name: Install Sandstorm (dev, unattended)
# Provides the `spk` command used to publish, and the sandstorm-http-bridge that
# build-deps.sh copies into the meteor-spk deps.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (same as sandstorm.yml)
run: |
bash sandstorm-src/build-deps.sh
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
- name: Restore the Sandstorm signing keyring
env:
SANDSTORM_KEYRING: ${{ secrets.SANDSTORM_KEYRING }}
run: |
if [ -n "${SANDSTORM_KEYRING:-}" ]; then
# spk publish reads the app signing key from ~/.sandstorm-keyring (a FILE in
# $HOME), same location meteor-spk pack uses.
printf '%s' "$SANDSTORM_KEYRING" | base64 -d > "$HOME/.sandstorm-keyring"
chmod 600 "$HOME/.sandstorm-keyring"
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
else
echo "::error::SANDSTORM_KEYRING secret is not set. 'spk publish' proves ownership of the WeKan Sandstorm app with the private key from this keyring, so publishing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
- name: Download the newest .spk from the WeKan release and name it wekan-<VERSION>.spk
id: fetch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
IN_TAG: ${{ github.event.inputs.tag }}
IN_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
# 1) Which release to publish from.
TAG="${IN_TAG:-}"
if [ -z "$TAG" ]; then
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
fi
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
echo "::error::No release found to publish the .spk from."
exit 1
fi
# 2) VERSION for the store filename wekan-<VERSION>.spk. Use the input, else
# derive it from the tag (strip a leading 'v', e.g. v9.88 -> 9.88).
VERSION="${IN_VERSION:-}"
if [ -z "$VERSION" ]; then
VERSION="${TAG#v}"
fi
echo "Release: $TAG -> store filename: wekan-${VERSION}.spk"
# 3) Download the newest .spk asset on that release. sandstorm.yml attaches it
# as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk; if several exist, take the
# lexicographically-newest (the timestamp sorts chronologically).
ASSET="$(gh release view "$TAG" --json assets \
--jq '[.assets[].name | select(endswith(".spk"))] | sort | last' 2>/dev/null || true)"
if [ -z "${ASSET:-}" ] || [ "$ASSET" = "null" ]; then
echo "::error::Release $TAG has no .spk asset. Run sandstorm.yml first to build and attach one."
exit 1
fi
echo "Downloading .spk asset: $ASSET"
gh release download "$TAG" --pattern "$ASSET" --dir . --clobber
SPK="wekan-${VERSION}.spk"
mv -f "$ASSET" "$SPK"
ls -la "$SPK"
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
- name: Publish to the Sandstorm App Market
env:
SPK: ${{ steps.fetch.outputs.spk }}
run: |
set -euo pipefail
# Resolve the spk binary: prefer PATH, then the Sandstorm install location.
SPK_BIN="$(command -v spk || true)"
if [ -z "$SPK_BIN" ]; then
for c in /opt/sandstorm/latest/bin/spk /opt/sandstorm/spk /usr/local/bin/spk; do
[ -x "$c" ] && SPK_BIN="$c" && break
done
fi
if [ -z "$SPK_BIN" ]; then
echo "::error::Could not find the 'spk' command from the Sandstorm install."
exit 1
fi
echo "Publishing $SPK with $SPK_BIN ..."
# Same command as releases/release-sandstorm.sh: spk publish wekan-<VERSION>.spk
"$SPK_BIN" publish "$SPK"
+144
View File
@@ -0,0 +1,144 @@
name: Build Sandstorm .spk
# Manually triggered. Builds ONLY the Sandstorm .spk package (nothing else) and
# uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the NEWEST WeKan GitHub
# Release as an attachment, so you can download it, test whether the Sandstorm
# build works, and see any errors — without running a full release.
#
# gh workflow run sandstorm.yml
#
# EXPERIMENTAL in CI (same caveats as the build-sandstorm job in release-all.yml):
# * Sandstorm needs unprivileged user namespaces; GitHub's Ubuntu runners are VMs
# that allow them, but this is the fragile part.
# * meteor-spk SIGNS the .spk with the app private key from the Sandstorm keyring;
# provide it as the SANDSTORM_KEYRING secret (base64 of ~/.sandstorm keyring).
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch, tag or SHA to build the .spk from (default: main)'
required: false
default: 'main'
permissions:
contents: write
env:
NODE_VERSION: '24'
jobs:
sandstorm:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
with:
# Build the dispatched ref so a fix branch can be tested before it is
# merged to main; defaults to main for a plain `gh workflow run`.
ref: ${{ github.event.inputs.ref || 'main' }}
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
# which blocks the unprivileged user namespaces that the Sandstorm install and
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies and Meteor
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git qemu-user-static
if [ ! -f "$HOME/.meteor/meteor" ]; then curl https://install.meteor.com/ | sh; fi
echo "$HOME/.meteor" >> "$GITHUB_PATH"
- name: Install Sandstorm (dev, unattended)
# Installed BEFORE assembling the deps: build-deps.sh's [bridge] step copies
# the modern /opt/sandstorm/latest/bin/sandstorm-http-bridge over the ancient
# bundled one, so the Sandstorm install must be in place first.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (Node 24 + FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
# niscud). build-deps.sh swaps in Node 24, adds FerretDB + the Mongo 3.x
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
# on the retired releases.wekan.team or the old projects.7z.
run: |
bash sandstorm-src/build-deps.sh
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
- name: Restore the Sandstorm signing keyring
env:
SANDSTORM_KEYRING: ${{ secrets.SANDSTORM_KEYRING }}
run: |
if [ -n "${SANDSTORM_KEYRING:-}" ]; then
# meteor-spk / spk read the app signing key from ~/.sandstorm-keyring —
# a FILE directly in $HOME (see the pack crash path
# "/home/runner/.sandstorm-keyring"). Write it exactly there, NOT to
# ~/.sandstorm/sandstorm-keyring, or `meteor-spk pack` aborts with
# "open(~/.sandstorm-keyring): No such file or directory".
printf '%s' "$SANDSTORM_KEYRING" | base64 -d > "$HOME/.sandstorm-keyring"
chmod 600 "$HOME/.sandstorm-keyring"
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
else
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
- name: Install Meteor npm dependencies
# PUPPETEER_SKIP_DOWNLOAD: puppeteer is a devDependency used only by tests,
# not by the server, so it never belongs in the packed .spk. Skipping its
# Chromium download (~150 MB) avoids any risk of it bloating the build and
# speeds up CI.
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
run: |
(meteor update --npm 2>/dev/null || true) && meteor npm install
- name: Build the Sandstorm .spk
id: build
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
run: |
set -euo pipefail
STAMP="$(date +'%Y_%m_%d-%H_%M_%S')"
SPK="wekan-sandstorm-${STAMP}.spk"
meteor-spk pack "$SPK"
# Report the packed size and warn if it exceeds Cloudflare's 100 MB upload
# cap (a proxied browser upload to a Cloudflare-fronted Sandstorm 413s above
# this; install via a DNS-only host / SSH tunnel, or trim further).
SPK_BYTES=$(stat -c%s "$SPK")
echo "Packed $SPK: $((SPK_BYTES/1024/1024)) MiB ($SPK_BYTES bytes)"
if [ "$SPK_BYTES" -gt $((100*1024*1024)) ]; then
echo "::warning::$SPK is $((SPK_BYTES/1024/1024)) MiB — over Cloudflare's 100 MB upload limit. Upload via a DNS-only host / SSH tunnel to Sandstorm, or trim the bundle further."
fi
ls -la "$SPK"
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.build.outputs.spk }}
path: ${{ steps.build.outputs.spk }}
retention-days: 7
- name: Upload the .spk to the newest WeKan release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
if [ -z "${TAG:-}" ] || [ "$TAG" = "null" ]; then
echo "::error::No existing GitHub Release found to attach the .spk to."
exit 1
fi
echo "Uploading ${{ steps.build.outputs.spk }} to the newest release: $TAG"
gh release upload "$TAG" "${{ steps.build.outputs.spk }}" --clobber
+14
View File
@@ -70,3 +70,17 @@ test-results/
_build-local-test
*/build-assets-local-test
*/build-chunks-local-test
# Local-only working copies: other wekan repos cloned under this repo for editing,
# and build inputs. These are separate git repos / large machine-specific dirs;
# never commit them.
# - projects/ : unarchived meteor-spk fork (Node/Mongo/FerretDB binaries)
# - sandstorm/ : clone of sandstorm-io/sandstorm (platform source, for reference)
# - FerretDB/ : clone of wekan/FerretDB
# - mongo-tools/ : clone of wekan/mongo-tools
# - mongosh/ : clone of wekan/mongosh
/projects/
/sandstorm/
/FerretDB/
/mongo-tools/
/mongosh/
+1 -1
View File
@@ -1,6 +1,6 @@
[main]
host = https://www.transifex.com
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja-JP, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
[o:wekan:p:wekan:r:application]
file_filter = imports/i18n/data/<lang>.i18n.json
+4003 -158
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,10 +2,10 @@
For all code at WeKan GitHub Organization https://github.com/wekan
- For pull requests, mention only those participants that are **human**.
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become MIT license.
- For any dependencies, permissive licenses like https://copyfree.org are preferred
- For anything currently that is non-permissive (like GPL, AGPL, SSPL), those will be replaced with permissive-licensed alternatives
- Any intelligence allowed: Human, AI or any other. No discrimination, please have world peace. Mentioning participants optional.
- All code is checked and validated by xet7 and many different security AI. If code is real working fix or feature, it usually will be merged.
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
+36 -10
View File
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=9.74
ARG VERSION=10.09
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
@@ -185,24 +185,35 @@ apt-get update --assume-yes
apt-get upgrade --assume-yes
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS}
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name
# arm/v7 uses TARGETARCH=arm; armhf has no MongoDB Community -> uses FerretDB
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name.
# amd64/arm64 have MongoDB Community; ppc64le/s390x/riscv64 have no MongoDB server
# and ship FerretDB v1 instead (the ferretdb binary is baked into their .zip and
# started by wekan-entrypoint.sh). riscv64's Node.js 24 comes from
# unofficial-builds.nodejs.org (nodejs.org ships no riscv64). armv7l is excluded:
# there is no Node.js 24 build for it anywhere.
NODE_BASE="official"
case "${TARGETARCH}" in
"amd64") NODE_ARCH="x64" WEKAN_ARCH="amd64" ;;
"arm64") NODE_ARCH="arm64" WEKAN_ARCH="arm64" ;;
"arm") NODE_ARCH="armv7l" WEKAN_ARCH="armhf" ;;
"ppc64le") NODE_ARCH="ppc64le" WEKAN_ARCH="ppc64le" ;;
"s390x") NODE_ARCH="s390x" WEKAN_ARCH="s390x" ;;
"riscv64") NODE_ARCH="riscv64" WEKAN_ARCH="riscv64" NODE_BASE="unofficial" ;;
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;;
esac
# Node.js Installation (official nodejs.org builds for Node 22)
# Node.js installation. Official nodejs.org builds for amd64/arm64/ppc64le/s390x;
# unofficial-builds.nodejs.org for riscv64 (nodejs.org ships no riscv64 binary).
cd /tmp
wget "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz"
wget "https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc"
grep "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc | shasum -a 256 -c -
if [ "${NODE_BASE}" = "unofficial" ]; then
NODE_DIST="https://unofficial-builds.nodejs.org/download/release/${NODE_VERSION}"
else
NODE_DIST="https://nodejs.org/dist/${NODE_VERSION}"
fi
wget "${NODE_DIST}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz"
wget "${NODE_DIST}/SHASUMS256.txt"
grep " node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz\$" SHASUMS256.txt | shasum -a 256 -c -
tar xzf "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc
rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
# NPM configuration
@@ -229,6 +240,14 @@ rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
npm install --prefix ./bundle/programs/server
mv /home/wekan/app/bundle /build
# The .zip bundle now ships a self-contained launcher + its own Node.js for the
# offline downloads; the Docker image installs its own Node and uses
# wekan-entrypoint.sh, so drop the redundant bundled node + launchers. Keeps
# /build/ferretdb (used by the entrypoint) and the per-arch MongoDB Database Tools
# (bsondump, mongodump, mongorestore, … from wekan/mongo-tools, embedded in the
# bundle) for backup/restore inside the container. Saves ~80 MB per arch.
rm -f /build/node /build/start-wekan.sh /build/start-wekan.bat
# Restore original tar
mv $(which tar)~ $(which tar)
@@ -247,10 +266,17 @@ mkdir -p /data
chown wekan:wekan --recursive /data
EOR
# Database-backend selector entrypoint. Every arch's bundle now ships a FerretDB
# binary (baked into the .zip and moved to /build/ferretdb above); this script
# starts FerretDB v1 (SQLite) or leaves the DB external based on WEKAN_DB and the
# /build/.ferretdb-default marker (present only on MongoDB-less arches). See
# releases/ferretdb/wekan-entrypoint.sh.
COPY --chmod=755 releases/ferretdb/wekan-entrypoint.sh /build/wekan-entrypoint.sh
USER wekan
ENV PORT=8080
EXPOSE $PORT
STOPSIGNAL SIGKILL
WORKDIR /build
CMD ["bash", "-c", "ulimit -s 65500; exec node main.js"]
CMD ["bash", "/build/wekan-entrypoint.sh"]
+22 -14
View File
@@ -67,7 +67,7 @@ that by providing one-click installation on various platforms.
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to about 105 languages.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 154 languages.
- [Features][features]: WeKan ® has real-time user interface.
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
@@ -103,10 +103,10 @@ By working directly with WeKan ® you get the benefit of active maintenance and
## Getting Started with Development
The default branch uses Meteor 3.5-rc.2 with Node.js 24.x.
The main branch uses Meteor 3.5 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-install-dependencies-build-wekan-and-run-the-dev-server). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
@@ -138,11 +138,17 @@ curl https://install.meteor.com/ | sh
### Building WeKan
The `rebuild-wekan.sh` script guides you through a three-stage process:
The `rebuild-wekan.sh` script shows a two-level menu. The top level groups options into categories:
1. **Install dependencies (Option 1)** - Downloads all required Meteor packages and npm modules
2. **Build WeKan (Option 2)** - Compiles the application
3. **Run WeKan (Option 3)** - Starts the development server at http://localhost:4000
```
1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit
```
You pick a category number, then the item number inside it (each submenu also has a `Back` entry). Building from source is a three-stage process:
1. **Setup -> Install dependencies** - Downloads all required Meteor packages and npm modules
2. **Setup -> Build WeKan** - Compiles the application
3. **Dev server -> localhost:3000** - Starts the development server at http://localhost:3000
```bash
# Clone your fork
@@ -152,25 +158,27 @@ cd wekan
# Make the script executable
chmod +x rebuild-wekan.sh
# Step 1: Install dependencies
# Step 1: Install dependencies (Setup -> Install dependencies)
./rebuild-wekan.sh
# Press 1 and Enter
# Press 1 (Setup) and Enter, then 1 (Install dependencies) and Enter
# Step 2: Build WeKan (after dependencies complete)
# Step 2: Build WeKan (Setup -> Build WeKan, after dependencies complete)
./rebuild-wekan.sh
# Press 2 and Enter
# Press 1 (Setup) and Enter, then 2 (Build WeKan) and Enter
# Step 3: Run WeKan in development mode
# Step 3: Run WeKan in development mode (Dev server -> localhost:3000)
./rebuild-wekan.sh
# Press 3 and Enter
# Press 2 (Dev server) and Enter, then 1 (localhost:3000) and Enter
```
If a dev server is already running on that port, the **Dev server** options stop it automatically and start a fresh server on the same port.
### WSL Users
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
The development server will start at http://localhost:4000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
The development server will start at http://localhost:3000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
## Screenshot
+28 -21
View File
@@ -10,8 +10,19 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
- **Report**: Click "New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
More info at [GitHub Docs about reporting privately](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately).
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
- **If GitHub does not work**: Please send report to **security@wekan.fi**
- **Optional Email Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
- Email is NOT secure or private.
- Using GitHub directly verifies that:
- You are:
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
- A correct GitHub user
- Really reading this SECURITY.md
- Knowing what you are doing
- You are not:
- Someone that tries to waste my time
- Trying to impersonate as some other GitHub user
- Spammer
- Bot
### 2. WeKan Security Team:
@@ -23,30 +34,26 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
### 3. Post-Release and Public Disclosure:
- Publicity: If you publish blog posts, give talks, or write articles about the
reported vulnerabilities, please send the links to us. We will link them
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
## How Should Reports Be Formatted?
```
Name: %name
Twitter: %twitter
Bug type: %bugtype
Domain: %domain
Severity: %severity
URL: %url
PoC: %poc
CVSS (optional): %cvss
CWSS (optional): %cwss
```
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
- Email is NOT secure or private.
- Using GitHub directly verifies that:
- You are:
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
- A correct GitHub user
- Really reading this SECURITY.md
- Knowing what you are doing
- You are not:
- Someone that tries to waste my time
- Trying to impersonate as some other GitHub user
- Spammer
- Bot
## Who can participate in the program
Anyone who reports a unique security issue in scope and does not disclose it to
a third party before we have patched and updated may be upon their approval
added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
a third party before we have patched, and is able to add
"New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
## Which domains are in scope?
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.74.0"
appVersion: "v10.09.0"
files:
userUploads:
- README.md
+67
View File
@@ -37,6 +37,8 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py lists BOARDID # Lists of BOARDID
python3 api.py list BOARDID LISTID # Info of LISTID
python3 api.py createlist BOARDID LISTTITLE # Create list
python3 api.py editlist BOARDID LISTID NEWLISTTITLE # Edit list title
python3 api.py editlistcolor BOARDID LISTID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit list color
python3 api.py addcard AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION
python3 api.py editcard BOARDID LISTID CARDID NEWCARDTITLE NEWCARDDESCRIPTION
python3 api.py getcard BOARDID LISTID CARDID # Get card info
@@ -130,6 +132,16 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles, cardAging, cardAgingDays1/2/3)
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true, cardAging true, cardAgingDays1 5
Board Automation Rules API (IFTTT, Issue #2674):
python3 api.py listrules BOARDID # List automation rules of a board
python3 api.py getrule BOARDID RULEID # Get one rule with its trigger and action
python3 api.py addrule BOARDID TITLE TRIGGER_JSON ACTION_JSON # Add rule. Missing trigger matching fields default to the '*' wildcard
python3 api.py editrule BOARDID RULEID PATCH_JSON # Edit rule: {"title":..,"trigger":{..},"action":{..}}
python3 api.py removerule BOARDID RULEID # Remove rule (and its trigger + action)
# Example (Issue #2674) - add member when card moved TO list, remove member when moved AWAY FROM list:
# python3 api.py addrule BOARDID 'Add on move to Doing' '{"activityType":"moveCard","listName":"Doing"}' '{"actionType":"addMember","username":"someuser"}'
# python3 api.py addrule BOARDID 'Remove on move from Doing' '{"activityType":"moveCard","oldListName":"Doing"}' '{"actionType":"removeMember","username":"someuser"}'
Admin API:
python3 api.py newuser USERNAME EMAIL PASSWORD
@@ -432,6 +444,49 @@ if arguments == 5:
if arguments >= 4:
if sys.argv[1] == 'editlist':
# ------- EDIT LIST START -----------
boardid = sys.argv[2]
listid = sys.argv[3]
newlisttitle = sys.argv[4]
edlist = wekanurl + apiboards + boardid + s + l + s + listid
print(edlist)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'title': '{}'.format(newlisttitle)}
body = requests.put(edlist, data=put_data, headers=headers)
print("=== EDIT LIST ===\n")
body = requests.get(edlist, headers=headers)
data2 = body.text.replace('}', "}\n")
print(data2)
# ------- EDIT LIST END -----------
if sys.argv[1] == 'editlistcolor':
# ------- EDIT LIST COLOR START -----------
boardid = sys.argv[2]
listid = sys.argv[3]
newcolor = sys.argv[4]
valid_colors = ['white', 'green', 'yellow', 'orange', 'red', 'purple', 'blue', 'sky', 'lime', 'pink', 'black',
'silver', 'peachpuff', 'crimson', 'plum', 'darkgreen', 'slateblue', 'magenta', 'gold', 'navy',
'gray', 'saddlebrown', 'paleturquoise', 'mistyrose', 'indigo']
if newcolor not in valid_colors:
print("Invalid color. Choose a color from the list.")
sys.exit(1)
edlist = wekanurl + apiboards + boardid + s + l + s + listid
print(edlist)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'color': '{}'.format(newcolor)}
body = requests.put(edlist, data=put_data, headers=headers)
print("=== EDIT LIST COLOR ===\n")
body = requests.get(edlist, headers=headers)
data2 = body.text.replace('}', "}\n")
print(data2)
# ------- EDIT LIST COLOR END -----------
if sys.argv[1] == 'newuser':
# ------- CREATE NEW USER START -----------
@@ -1728,6 +1783,18 @@ if arguments >= 1:
# python3 api.py addrule BOARDID 'Archive after 90 days' \
# '{"activityType":"scheduledTrigger","scheduleKind":"aging","listName":"Completed","days":90,"atTime":"03:00"}' \
# '{"actionType":"archive"}'
#
# Example (Issue #2674 - add a user to the card when it is moved TO a list, and
# remove the user again when the card is moved AWAY FROM that list). Trigger
# matching fields that are omitted (userId, swimlaneName, cardTitle, and the
# unused one of listName/oldListName) are stored as the '*' wildcard by the
# server, so the rules fire for any user/swimlane/card title:
# python3 api.py addrule BOARDID 'Add member on move to Doing' \
# '{"activityType":"moveCard","listName":"Doing"}' \
# '{"actionType":"addMember","username":"someuser"}'
# python3 api.py addrule BOARDID 'Remove member on move from Doing' \
# '{"activityType":"moveCard","oldListName":"Doing"}' \
# '{"actionType":"removeMember","username":"someuser"}'
if arguments >= 2 and sys.argv[1] == 'listrules':
boardid = sys.argv[2]
+10 -4
View File
@@ -1,7 +1,13 @@
// Fix dynamic-import chunk loading when Wekan runs under a sub-path (ROOT_URL includes a pathname,
// e.g. https://example.com/wekan). Rspack uses __webpack_public_path__ to prefix chunk URLs; by
// default it is set to /build-chunks/ (no context path). We update it here, at module evaluation
// time, before any import() expression runs (those are deferred to Meteor.startup callbacks).
// e.g. https://example.com/wekan). Rspack's runtime builds every chunk URL as
// __webpack_public_path__ + chunkName, and the chunk name ALREADY includes the "build-chunks/"
// prefix (its runtime does `u = e => "build-chunks/" + e + "." + hash + ".js"`). The default
// public path is "/", so at the site root chunks correctly resolve to "/build-chunks/<chunk>".
// Under a sub-path we must prefix that sub-path — but must NOT re-add "build-chunks/" ourselves,
// or the URL becomes "/wekan/build-chunks/build-chunks/<chunk>" and 404s (issue #6445, which broke
// the lazy-loaded language chunks). So set the public path to "<sub-path>/" and let rspack append
// "build-chunks/" itself. Done here at module evaluation time, before any import() expression runs
// (those are deferred to Meteor.startup callbacks).
/* global __webpack_public_path__:writable */
try {
const _cfg = typeof window !== 'undefined' && window.__meteor_runtime_config__;
@@ -9,7 +15,7 @@ try {
if (_rootUrl) {
const _rootPath = new URL(_rootUrl).pathname.replace(/\/+$/, '');
if (_rootPath && _rootPath !== '/') {
__webpack_public_path__ = _rootPath + '/build-chunks/';
__webpack_public_path__ = _rootPath + '/';
}
}
} catch (_) {}
@@ -15,6 +15,20 @@
.reactions-popup .add-comment-reaction:hover {
background-color: #b0c4de;
}
/* Eye / eye-slash icon toggle for showing/hiding activities. Open eye
(.eye-icon) = activities shown; crossed eye (.eye-slash-icon) = hidden.
Matches the password-visibility toggle used on the login/register pages. */
.activities-eye-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4em;
text-decoration: none;
}
.activities-eye-toggle .eye-icon,
.activities-eye-toggle .eye-slash-icon {
line-height: 1;
}
.activities {
clear: both;
}
+5 -2
View File
@@ -106,10 +106,10 @@ template(name="activity")
//- comment activity ----------------------------------------------------
if($eq activity.activityType 'deleteComment')
| {{{_ 'activity-deleteComment' activity.commentId}}}.
| {{{_ 'activity-deleteComment' (sanitize activity.commentDisplayText)}}}.
if($eq activity.activityType 'editComment')
| {{{_ 'activity-editComment' activity.commentId}}}.
| {{{_ 'activity-editComment' (sanitize activity.commentDisplayText)}}}.
if($eq activity.activityType 'addComment')
| {{{_ 'activity-on' cardLink}}}
@@ -133,6 +133,9 @@ template(name="activity")
if($eq activity.activityType 'a-changedTitle')
| {{{_ 'activity-changedTitle' (sanitize activity.value) cardLink}}}.
if($eq activity.activityType 'a-changedDescription')
| {{{_ 'activity-changedDescription' cardLink}}}.
//- customField activity ------------------------------------------------
if($eq activity.activityType 'createCustomField')
| {{_ 'activity-customfield-created' customField}}.
+14 -4
View File
@@ -7,7 +7,12 @@ import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { getSidebarInstance } from '/client/features/sidebar/service';
const activitiesPerPage = 500;
// #6480/#6481: 500 was far more than the sidebar/card activity feed ever shows
// at once, and with FerretDB (no oplog) every live cursor is re-run on a timer —
// so a 500-row activities cursor was re-fetched and re-diffed on every poll for
// each open board/card. 50 covers the visible feed; infinite scroll still pulls
// the next page on demand (the server cursor is index-bounded either way).
const activitiesPerPage = 50;
Template.activities.onCreated(function () {
// Register with sidebar so it can call loadNextPage on us
@@ -234,7 +239,13 @@ Template.activity.helpers({
sourceLink() {
const source = this.activity.source;
if (source) {
if (source.url) {
// XSS fix (reported by meifukun): source.url is imported from an external
// board (e.g. a Trello board's `url`) and was rendered as an <a href> with
// no scheme check, so a `javascript:` (or data:/vbscript:) URL executed in
// the board admin's session on click and could exfiltrate Meteor.loginToken.
// Only render a link for an http(s) URL; otherwise show the plain (sanitized)
// source name with no href.
if (source.url && /^https?:\/\//i.test(String(source.url))) {
return Blaze.toHTML(
HTML.A(
{
@@ -243,9 +254,8 @@ Template.activity.helpers({
sanitizeHTML(source.system),
),
);
} else {
return sanitizeHTML(source.system);
}
return sanitizeHTML(source.system);
}
return null;
},
+79 -2
View File
@@ -10,7 +10,61 @@ const commentFormIsOpen = new ReactiveVar(false);
// Empty string means a new top-level comment.
const replyToCommentId = new ReactiveVar('');
// #5547: persist the comment draft (debounced) into UnsavedEdits while the user
// types, so a comment in progress survives closing the card, clicking outside
// it, or navigating away — the form prefills from {{getUnsavedValue
// 'cardComment' ...}} on the next open. Historical note: the click-open flag
// that armed the old escape-time draft save was removed in 2019 (3b3950369)
// because the escape handler CLEARED the visible text; since then nothing saved
// the draft at all, so any close of the card discarded the comment.
let commentDraftTimer = null;
let commentDraftPending = null; // { cardId, value }
function commentDraftWrite({ cardId, value }) {
if (!cardId) return;
const draftKey = { fieldName: 'cardComment', docId: cardId };
if ((value || '').trim()) {
UnsavedEdits.set(draftKey, value);
} else {
UnsavedEdits.reset(draftKey);
}
}
function scheduleCommentDraftSave(cardId, value) {
commentDraftPending = { cardId, value };
if (commentDraftTimer) clearTimeout(commentDraftTimer);
commentDraftTimer = setTimeout(() => {
commentDraftTimer = null;
const pending = commentDraftPending;
commentDraftPending = null;
if (pending) commentDraftWrite(pending);
}, 300);
}
// Run a pending (debounced) save immediately — e.g. the card is closing.
function flushCommentDraftSave() {
if (commentDraftTimer) {
clearTimeout(commentDraftTimer);
commentDraftTimer = null;
}
const pending = commentDraftPending;
commentDraftPending = null;
if (pending) commentDraftWrite(pending);
}
// Drop any pending save — the comment was submitted, so there is no draft.
function cancelCommentDraftSave() {
if (commentDraftTimer) {
clearTimeout(commentDraftTimer);
commentDraftTimer = null;
}
commentDraftPending = null;
}
Template.commentForm.onDestroyed(function () {
// #5547: the card can close without the escape handler ever running (the X
// button, a route change); make sure the last keystrokes reach the draft.
flushCommentDraftSave();
commentFormIsOpen.set(false);
$('.note-popover').hide();
});
@@ -28,6 +82,15 @@ Template.commentForm.helpers({
});
Template.commentForm.events({
// #5547: arm the escape handler below (it saves the draft and closes the
// form); this flag had no setter since 2019, so the handler never ran.
'focus .js-new-comment-input'() {
commentFormIsOpen.set(true);
},
// #5547: keep the draft continuously saved (debounced) while typing.
'input .js-new-comment-input'(evt) {
scheduleCommentDraftSave(Utils.getCurrentCardId(), evt.currentTarget.value);
},
'submit .js-new-comment-form'(evt, tpl) {
const input = tpl.$('.js-new-comment-input');
const text = input.val().trim();
@@ -48,6 +111,13 @@ Template.commentForm.events({
cardId,
parentId: parentId || '',
});
// #5547: the comment is saved — cancel any debounced draft save still in
// flight and remove the stored draft so it does not resurface next open.
cancelCommentDraftSave();
UnsavedEdits.reset({
fieldName: 'cardComment',
docId: Utils.getCurrentCardId(),
});
replyToCommentId.set('');
resetCommentInput(input);
Tracker.flush();
@@ -159,13 +229,20 @@ EscapeActions.register(
docId: Utils.getCurrentCardId(),
};
const commentInput = $('.js-new-comment-input');
const draft = commentInput.val().trim();
const draft = (commentInput.val() || '').trim();
cancelCommentDraftSave();
if (draft) {
UnsavedEdits.set(draftKey, draft);
} else {
UnsavedEdits.reset(draftKey);
}
resetCommentInput(commentInput);
// #5547: do NOT clear the input — clearing it on every click outside the
// comment box is exactly the "comment text disappearing" bug that got this
// handler disarmed in 2019 (3b3950369). The draft is saved above; if the
// card stays open, the text stays visible; if the card closes, the next
// open prefills the form from the draft.
commentInput.blur();
commentFormIsOpen.set(false);
},
() => {
return commentFormIsOpen.get();
+10 -2
View File
@@ -4,14 +4,22 @@ template(name="archivedBoards")
i.fa.fa-archive
| {{_ 'archived-boards'}}
.admin-report-controls
input.js-archived-boards-search(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-archived-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-archived-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
ul.archived-lists
each archivedBoards
li.archived-lists-item
div.board-header-btns
button.primary.board-header-btn.js-delete-board
button.primary.board-header-btn.js-delete-board(type="submit")
i.fa.fa-trash
| {{_ 'delete-board'}}
button.primary.board-header-btn.js-restore-board
button.primary.board-header-btn.js-restore-board(type="submit")
i.fa.fa-undo
| {{_ 'restore-board'}}
= title
+45 -6
View File
@@ -3,8 +3,22 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Boards from '/models/boards';
import { Utils } from '/client/lib/utils';
const ARCHIVED_BOARDS_PER_PAGE = 30;
Template.archivedBoards.onCreated(function () {
this.subscribe('archivedBoards');
// Server-side pagination: only the current page of archived boards is ever
// published/loaded, so a long-lived instance's archive stays fast.
this.page = new ReactiveVar(1);
this.total = new ReactiveVar(0);
this.searchQuery = new ReactiveVar('');
this.autorun(() => {
const searchTerm = this.searchQuery.get();
const skip = (this.page.get() - 1) * ARCHIVED_BOARDS_PER_PAGE;
this.subscribe('archivedBoards', searchTerm, ARCHIVED_BOARDS_PER_PAGE, skip);
Meteor.call('getArchivedBoardsCount', searchTerm, (err, count) => {
if (!err) this.total.set(count || 0);
});
});
});
Template.archivedBoards.helpers({
@@ -13,17 +27,42 @@ Template.archivedBoards.helpers({
},
archivedBoards() {
const ret = ReactiveCache.getBoards(
// Only the current page is in minimongo (the publication is limited).
return ReactiveCache.getBoards(
{ archived: true },
{
sort: { archivedAt: -1, modifiedAt: -1 },
},
{ sort: { archivedAt: -1, modifiedAt: -1 } },
);
return ret;
},
currentPage() { return Template.instance().page.get(); },
totalPages() { return Math.max(1, Math.ceil((Template.instance().total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE)); },
hasPrevPage() { return Template.instance().page.get() > 1; },
hasNextPage() {
const tpl = Template.instance();
return tpl.page.get() < Math.max(1, Math.ceil((tpl.total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE));
},
});
Template.archivedBoards.events({
'keydown .js-archived-boards-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-archived-boards-search').val() || '');
tpl.page.set(1);
}
},
'click .js-archived-boards-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-archived-boards-next-page'(event, tpl) {
event.preventDefault();
const total = tpl.total.get() || 0;
const totalPages = Math.max(1, Math.ceil(total / ARCHIVED_BOARDS_PER_PAGE));
const current = tpl.page.get();
if (current < totalPages) tpl.page.set(current + 1);
},
async 'click .js-restore-board'() {
// TODO : Make isSandstorm variable global
const isSandstorm =
+13 -1
View File
@@ -316,7 +316,19 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
display: none !important;
pointer-events: none !important;
}
.board-wrapper .board-canvas.is-dragging-active .open-minicard-composer,
/* #2877 "Wekan swimlane jumps when moving card": while a card is dragged the
"+ Add Card" composer link is hidden, but hiding it with `display: none`
collapsed the bottom row of EVERY list at drag start (and restored it on
drop). With auto-height swimlanes the whole swimlane shrank/grew by the
composer's height, so all swimlanes below visibly jumped up and back down.
`visibility: hidden` hides the link just the same but keeps its box in the
layout, so nothing moves when a drag starts or ends. */
.board-wrapper .board-canvas.is-dragging-active .open-minicard-composer {
visibility: hidden;
}
/* Checked cards of a multi-selection ARE the payload being dragged, so
removing them from the flow (showing the list as it will look after the
drop) is intentional — keep display: none here. */
.board-wrapper .board-canvas.is-dragging-active .minicard-wrapper.is-checked {
display: none;
}
+3 -1
View File
@@ -23,7 +23,7 @@ template(name="boardBody")
if debugBoardState
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-wrapper(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-canvas.js-swimlanes.dragscroll(
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
@@ -52,6 +52,8 @@ template(name="boardBody")
+ganttView
else if isViewTable
+tableView
else if isViewStats
+statsView
else
// Default view - show swimlanes if they exist, otherwise show lists
if hasSwimlanes
+55 -5
View File
@@ -6,11 +6,13 @@ import '/client/lib/dragscrollTouch';
import { boardConverter } from '/client/lib/boardConverter';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
import { weekNumberByFirstDay } from '/models/lib/weekStart';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { Filter } from '/client/lib/filter';
// SubsManager removed for Meteor 3 migration
const { calculateIndex } = Utils;
@@ -71,6 +73,9 @@ Template.board.onCreated(function () {
}
const handle = Meteor.subscribe('board', currentBoardId, false);
// Learn this board's card-loading mode (lazy vs eager) so 'auto' can decide
// per board by size; the flag drives isLazyCards(boardId). #6480.
Meteor.subscribe('boardCardsLoadingMode', currentBoardId);
const ready = handle.ready();
this.isBoardReady.set(ready);
@@ -568,17 +573,32 @@ Template.boardBody.onRendered(function () {
$swimlanesDom.sortable('option', 'handle', '.swimlane-header');
}
// Disable drag-dropping if the current user is not a board member
// #6446: enable swimlane drag-drop — which also carries moving cards BETWEEN
// swimlanes — for any board member with write access, not only board admins.
// The old !isBoardAdmin() check disabled this sortable for every non-admin, so
// a normal member could still move a card WITHIN a swimlane but not BETWEEN
// swimlanes (a card crossing swimlanes is received across this sortable), and
// could not reorder swimlanes. canModifyCard() is the client write-access
// predicate (board member, not comment-only / worker / read-only), matching
// the server's allowIsBoardMemberWithWriteAccess.
$swimlanesDom.sortable(
'option',
'disabled',
!ReactiveCache.getCurrentUser()?.isBoardAdmin(),
!Utils.canModifyCard(),
);
}
});
dragscroll.reset();
Utils.setBackgroundImage();
// #4978: apply the board background inside a reactive autorun instead of a
// one-shot call. Switching directly between boards (e.g. the favorites bar)
// reuses this template instance and never re-fires onRendered, so the
// background used to stay on the previously shown board. Reading the current
// board inside setBackgroundImage() tracks both board switches and background
// edits; it performs no DB writes, so there is no autorun write loop.
this.autorun(() => {
Utils.setBackgroundImage();
});
});
Template.boardBody.onDestroyed(function () {
@@ -688,6 +708,19 @@ Template.boardBody.helpers({
return boardView === 'board-view-table';
},
isViewStats() {
const currentUser = ReactiveCache.getCurrentUser();
let boardView;
if (currentUser) {
boardView = (currentUser.profile || {}).boardView;
} else {
boardView = window.localStorage.getItem('boardView');
}
return boardView === 'board-view-stats';
},
hasSwimlanes() {
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) {
@@ -929,6 +962,11 @@ Template.calendarView.onRendered(function () {
document.documentElement.dir = TAPi18n.getLanguageDirection();
this.autorun(function () {
// #5656: refetch calendar events whenever the active board Filter changes
// so the Calendar view stays in sync with the other views. Reading the
// Filter's reactive state here establishes the dependency; the events()
// callback below applies the actual selector.
Filter.isActive();
const calendarEl = document.getElementById('calendar-view');
if (calendarEl && calendarEl._wekanCalendar) {
calendarEl._wekanCalendar.refetchEvents();
@@ -959,6 +997,12 @@ Template.calendarView.helpers({
editable: true,
selectable: true,
weekNumbers: true,
// #4946: FullCalendar's default weekNumberCalculation is locale-based
// (Sunday-anchored for many locales), so the week-number column ignored
// the user's start-day-of-week setting even though `firstDay` moved the
// grid. Compute the number from the SAME firstDay so the label matches the
// way the week is laid out.
weekNumberCalculation: date => weekNumberByFirstDay(date, firstDay),
// Use non-localized AM/PM time format to avoid confusing notations like 上/下/中
// Use full 'am'/'pm' instead of single-letter 'a'/'p' for clarity
eventTimeFormat: {
@@ -1002,6 +1046,12 @@ Template.calendarView.helpers({
isRTL: TAPi18n.isRTL(),
events(fetchInfo, callback) {
const currentBoard = Utils.getCurrentBoard();
// #5656: apply the active board Filter (member / assignee / due-date /
// label / custom-field) so the Calendar view shows the same scoped set
// of cards as the Board / Lists / Swimlanes views.
const filterSelector = Filter.isActive()
? Filter._getMongoSelector()
: undefined;
const events = [];
const pushEvent = function (card, title, start, end, extraCls) {
start = start || card.startAt;
@@ -1026,12 +1076,12 @@ Template.calendarView.helpers({
});
};
currentBoard
.cardsInInterval(fetchInfo.start, fetchInfo.end)
.cardsInInterval(fetchInfo.start, fetchInfo.end, filterSelector)
.forEach(function (card) {
pushEvent(card);
});
currentBoard
.cardsDueInBetween(fetchInfo.start, fetchInfo.end)
.cardsDueInBetween(fetchInfo.start, fetchInfo.end, filterSelector)
.forEach(function (card) {
pushEvent(
card,
+48 -29
View File
@@ -23,15 +23,16 @@ template(name="boardHeaderBar")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
unless watchDisabled
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
@@ -67,14 +68,15 @@ template(name="boardHeaderBar")
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
unless watchDisabled
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
@@ -129,6 +131,8 @@ template(name="boardHeaderBar")
i.fa.fa-bar-chart
if $eq boardView 'board-view-table'
i.fa.fa-table
if $eq boardView 'board-view-stats'
i.fa.fa-pie-chart
span
if $eq boardView 'board-view-swimlanes'
| {{_ 'swimlanes'}}
@@ -140,6 +144,8 @@ template(name="boardHeaderBar")
| {{_ 'gantt'}}
if $eq boardView 'board-view-table'
| {{_ 'board-view-table'}}
if $eq boardView 'board-view-stats'
| {{_ 'board-view-stats'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
@@ -157,6 +163,8 @@ template(name="boardHeaderBar")
i.fa.fa-times-thin
.separator
//- Board Settings cog removed: Board Settings is available in the right
//- sidebar (the hamburger), so the header no longer duplicates it.
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
@@ -247,6 +255,13 @@ template(name="boardChangeViewPopup")
| {{_ 'board-view-table'}}
if $eq Utils.boardView "board-view-table"
i.fa.fa-check
li
with "board-view-stats"
a.js-open-stats-view
i.fa.fa-pie-chart
| {{_ 'board-view-stats'}}
if $eq Utils.boardView "board-view-stats"
i.fa.fa-check
template(name="createBoard")
form
@@ -267,9 +282,10 @@ template(name="createBoard")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
@@ -293,9 +309,10 @@ template(name="createBoardPopup")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
@@ -319,9 +336,10 @@ template(name="headerBarCreateBoardPopup")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
@@ -346,9 +364,10 @@ template(name="createTemplateContainerPopup")
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
+4 -1
View File
@@ -130,7 +130,6 @@ Template.boardHeaderBar.events({
Meteor.call('toggleBoardStar', boardId);
}
},
'click .js-open-board-menu': Popup.open('boardMenu'),
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
'click .js-watch-board': Popup.open('boardChangeWatch'),
'click .js-open-archived-board'() {
@@ -249,6 +248,10 @@ Template.boardChangeViewPopup.events({
Utils.setBoardView('board-view-table');
Popup.back();
},
'click .js-open-stats-view'() {
Utils.setBoardView('board-view-stats');
Popup.back();
},
});
// Shared setup for all create board popups
+160 -64
View File
@@ -1,13 +1,5 @@
@import url("../../../public/css/reset.css") print, screen;
/* Board List Header with Zoom Controls */
.board-list-header {
display: flex;
justify-content: center;
margin: 1vh 0 2vh 0;
padding: 1vh 0;
}
/* Two-column layout for All Boards */
.boards-layout {
display: grid;
@@ -260,6 +252,9 @@
align-items: center;
justify-content: space-between;
gap: 8px;
/* Wrap to a 2nd row on narrow windows so the search box and the Sort /
Multi-Selection / Clear buttons stay visible instead of overflowing. */
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 16px;
background: #f5f5f5;
@@ -273,6 +268,46 @@
align-items: center;
gap: 8px;
flex: 1;
min-width: 0; /* allow shrinking so path-right can wrap below instead of overflowing */
flex-wrap: wrap; /* the multi-selection hint wraps under the title when tight */
}
/* Keep the folder icon and the menu title (e.g. "Remaining") on ONE line, the
title to the RIGHT of the icon — never let flex-wrap stack them vertically. */
.boards-path-header .path-title {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
/* Multi-Selection button with its hint stacked underneath (own column). */
.boards-path-header .multiselection-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
/* "Selected:" actions row (star / home) below the Multi-Selection button. */
.boards-path-header .selected-actions {
display: flex;
align-items: center;
gap: 8px;
}
.boards-path-header .selected-label {
font-size: 13px;
color: #333;
}
.boards-path-header .selected-action {
cursor: pointer;
color: #333;
font-size: 16px;
line-height: 1;
padding: 2px 4px;
}
.boards-path-header .selected-action:hover {
color: #2980b9;
}
.boards-path-header .multiselection-hint {
@@ -295,6 +330,10 @@
display: flex;
align-items: center;
gap: 8px;
/* Buttons (Sort / Multi-Selection / Clear) wrap among themselves too, so none
is ever cut off on a narrow window. */
flex-wrap: wrap;
justify-content: flex-end;
}
/* #5799: board-name search box in the All Boards header */
@@ -306,6 +345,9 @@
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
/* Roughly half the previous width; the input flexes to fill what's left. */
width: 300px;
max-width: 100%;
}
.boards-path-header .board-search input {
@@ -313,7 +355,11 @@
outline: none;
background: transparent;
font-size: 14px;
min-width: 140px;
flex: 1 1 auto;
min-width: 0;
/* Nudge the text down so it sits at the vertical middle of the field. */
position: relative;
top: 8px;
}
.boards-path-header .board-search .board-search-clear {
@@ -517,19 +563,33 @@
overflow: hidden;
background-color: inherit; /* Inherit board color from parent li.js-board */
color: #f6f6f6;
min-height: 100px;
min-height: 72px; /* #6465: thinner tiles so more boards fit in the overview */
font-size: 16px;
line-height: 22px;
border-radius: 0; /* No border-radius - parent .js-board has it */
display: block;
font-weight: 700;
padding: 36px 8px 32px 8px; /* Top padding for drag handle, bottom for checkbox */
padding: 24px 8px 18px 8px; /* #6465: trimmed; top leaves room for the drag handle, bottom for the checkbox */
margin: 0; /* No margin - moved to parent .js-board */
position: relative;
text-decoration: none;
word-wrap: break-word;
}
/* #2220: Home-board badge — a home icon centred at the top of the board tile. */
.board-list .board-home-badge {
position: absolute;
top: 2px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
color: #fff;
font-size: 14px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.board-list .board-list-item > .js-open-board {
text-decoration: none;
color: inherit;
@@ -561,16 +621,27 @@
}
.board-list .js-add-board .label {
font-weight: normal;
line-height: 56px;
min-height: 100px;
/* Match the board tiles (#6465 thinner tiles) so the grey "+ Add Board" /
"+ Add Template Container" box is the same height as a board icon:
same min-height, padding and line-height as .board-list-item above. */
line-height: 22px;
min-height: 72px;
display: flex;
align-items: center;
justify-content: center;
background-color: #999; /* Darker background for better text contrast */
border-radius: 3px;
padding: 36px 8px 32px 8px;
/* Same total vertical padding as .board-list-item (42px -> same tile height),
but shifted up (top 24->18, bottom 18->24) so the "+ Add Board" text sits
higher. */
padding: 18px 8px 24px 8px;
color: #fff; /* White text */
}
/* "+ Add Template Container" text sits 2px lower than "+ Add Board" (same 42px
total vertical padding, so the tile height is unchanged). */
.board-list .js-add-board .label.is-template-container {
padding: 20px 8px 22px 8px;
}
.board-list .js-add-board .label i {
color: #fff; /* White icon */
}
@@ -668,12 +739,14 @@
.board-list .board-handle {
position: absolute;
padding: 4px 6px;
top: 4px;
left: 50%;
transform: translateX(-50%);
/* Right middle of the tile (matches the mobile/small-screen handle position). */
top: 50%;
inset-inline-end: 6px;
transform: translateY(-50%);
font-size: 14px;
color: #fff;
background: rgba(0,0,0,0.4);
/* Transparent background so only the drag-handle icon shows (not a box). */
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
@@ -812,20 +885,25 @@ body.grey-icons-enabled .checkmark-no-grey {
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
.board-list.mobile-view {
/* #6488: a real 2-column grid — robust regardless of float support or how narrow
the boards column is (at least 2 board icons per row on phones). */
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
height: calc(100vh - 120px);
overflow-y: scroll !important;
overflow-y: auto !important;
overflow-x: hidden;
padding: 0 1rem;
padding: 0 0.5rem;
margin: 0;
scrollbar-width: auto !important;
scrollbar-color: #888 #f1f1f1;
}
.board-list.mobile-view li {
width: 100%;
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 1rem;
padding-inline-end: 50px; /* Space for drag handle */
margin-bottom: 0.5rem;
padding-inline-end: 32px; /* Space for drag handle */
}
.board-list.mobile-view .board-list-item {
overflow: visible;
@@ -893,11 +971,6 @@ body.grey-icons-enabled .checkmark-no-grey {
background: #555 !important;
}
/* Force mobile view to have scrollable content */
.board-list.mobile-view {
min-height: 100vh; /* Force content to be tall enough to scroll */
}
/* Hide archive and clone board buttons in mobile view */
.board-list.mobile-view .js-archive-board,
.board-list.mobile-view .js-clone-board {
@@ -910,38 +983,41 @@ body.grey-icons-enabled .checkmark-no-grey {
font-family: inherit !important;
}
.board-list.mobile-view::after {
content: '';
display: block;
height: 100px;
}
@media screen and (max-width: 800px),
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
/* #6488 + mobile layout: keep the left menu on the left but narrow (so the boards
get room), and show the board icons on the right at least 2 per row. */
.boards-layout {
grid-template-columns: minmax(84px, 30%) 1fr;
gap: 8px;
}
.board-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 0 1rem;
padding: 0 0.25rem;
margin: 0;
}
.board-list li {
width: 100%;
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 1rem;
padding-inline-end: 50px; /* Space for drag handle */
margin-bottom: 0.5rem;
padding-inline-end: 32px; /* Space for drag handle */
}
.board-list .board-list-item {
overflow: visible;
height: 8rem;
width: 100%;
margin: 0;
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
padding-inline-end: 40px; /* Ensure content doesn't overlap with drag handle */
}
.board-list .board-list-item .details {
@@ -977,10 +1053,15 @@ body.grey-icons-enabled .checkmark-no-grey {
background: rgba(255, 255, 0, 0.8) !important; /* Yellow hover */
}
}
/* Very small screens - ensure one board per row */
/* Very small screens - still keep at least 2 board icons per row (#6488). */
@media screen and (max-width: 360px) {
.board-list {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 6px;
}
.board-list li {
width: 100% !important;
width: auto !important;
float: none !important;
display: block !important;
}
@@ -1009,20 +1090,16 @@ body.grey-icons-enabled .checkmark-no-grey {
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
/* Stack the two-column All Boards layout on small screens. The fixed
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
and the left menu (Starred / Templates / Remaining / Workspaces / + Add
board) was clipped. Width-based, so it applies to every browser. */
/* #6488: KEEP the two-column layout (narrow left menu, board icons on the RIGHT)
on small touch screens — do NOT stack the boards below the menu. Stacking pushed
the board icons under the menu + search bar, and dragscroll does not work over
the menu / search area, so boards below the fold were unreachable (you could not
drag-scroll down to them). The narrow minmax() menu set in the block above
already prevents the old fixed-260px overflow that used to clip the left menu, so
collapsing to one column is no longer needed. The board list stays in its own
right-hand column with its own overflow-y:auto scroll. */
.boards-layout {
grid-template-columns: 1fr !important;
}
.boards-left-menu {
border-inline-end: none !important;
border-bottom: 1px solid #e0e0e0;
padding-inline-end: 0 !important;
padding-bottom: 12px;
grid-template-columns: minmax(84px, 30%) 1fr !important;
}
}
.AllBoardTeamsOrgs {
@@ -1078,6 +1155,13 @@ body.grey-icons-enabled .checkmark-no-grey {
margin: 8px; /* Space between board items */
}
/* #6439: highlight the board a dragged board will be dropped before, when
reordering in the Custom (drag order) sort mode. */
.js-board.board-reorder-over {
outline: 2px dashed #3498db;
outline-offset: 2px;
}
/* Reset background for add-board button */
.js-add-board {
background-color: transparent !important;
@@ -1139,13 +1223,31 @@ body.grey-icons-enabled .checkmark-no-grey {
height: 100vh;
}
/* The left menu (Starred / Templates / Remaining / Workspaces / Add board) is its
own vertical scroll area, so when it is taller than the screen you can drag-scroll
down to its lower items — the board icons already scroll in their own column on the
right. Both columns are reachable in LTR and RTL: the grid columns and the logical
inline borders mirror automatically, so in RTL the menu is on the right and the
boards on the left. */
.boards-left-menu {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
max-height: calc(100vh - 120px);
}
.board-list {
overflow-y: scroll !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
height: calc(100vh - 120px); /* Ensure there's content to scroll */
/* #6488: a BOUNDED height so the boards overflow and scroll. The old rule set
min-height:100vh (plus a phantom ::after), which grew the list to fit all its
content — so nothing overflowed and, clipped by the overflow:hidden wrapper,
boards below the fold were unreachable. */
height: calc(100vh - 120px);
max-height: calc(100vh - 120px);
}
/* Force scrollbar to always be visible */
@@ -1182,14 +1284,8 @@ body.grey-icons-enabled .checkmark-no-grey {
.board-list {
scrollbar-gutter: stable;
scrollbar-width: auto !important;
min-height: 100vh; /* Force content to be tall enough to scroll */
}
/* Ensure there's always content to scroll */
.board-list::after {
content: '';
display: block;
height: 100px;
/* NOTE: no min-height/::after here — the bounded height above is what makes the
list scroll; forcing it 100vh tall would defeat that (see #6488). */
}
/* Ensure only one scrollbar is visible */
+53 -30
View File
@@ -1,7 +1,5 @@
template(name="boardList")
.wrapper
.board-list-header
.boards-layout
// Left menu
.boards-left-menu
@@ -77,26 +75,11 @@ template(name="boardList")
.boards-right-grid
.boards-path-header
.path-left
span.path-icon.emoji-icon {{currentMenuPath.icon}}
span.path-text {{currentMenuPath.text}}
if BoardMultiSelection.isActive
span.multiselection-hint
span.emoji-icon
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
span.path-title
span.path-icon.emoji-icon {{currentMenuPath.icon}}
span.path-text {{currentMenuPath.text}}
.path-right
.board-search
span.emoji-icon
i.fa.fa-search
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
span {{_ 'sort'}}
//- Order (left -> right): Multi-Selection, Sort, Search boards.
if canModifyBoards
if hasBoardsSelected
button.js-archive-selected-boards.board-header-btn
@@ -107,13 +90,41 @@ template(name="boardList")
span.emoji-icon
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}
a.board-header-btn.js-multiselection-activate(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
span.emoji-icon
//- Multi-Selection button, with the "click checkboxes" hint stacked
directly BELOW it (own column) instead of beside the path title.
.multiselection-group
a.board-header-btn.js-multiselection-activate(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
span {{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
if BoardMultiSelection.isActive
span.multiselection-hint
span.emoji-icon
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
//- "Selected:" actions on the multi-selected boards: star them,
or set one as the Home board (opened after login, #2220).
.selected-actions
span.selected-label {{_ 'selected-label'}}
a.selected-action.js-star-selected(title="{{_ 'set-selected-starred'}}")
i.fa.fa-star
a.selected-action.js-home-selected(title="{{_ 'set-selected-home'}}")
i.fa.fa-home
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
span {{_ 'sort'}}
//- Search boards is placed to the RIGHT of the Multi-Selection button.
.board-search
span.emoji-icon
i.fa.fa-search
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
if boardsPaginationActive
@@ -126,7 +137,7 @@ template(name="boardList")
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
li.js-add-board
if isSelectedMenu 'templates'
a.board-list-item.label(title="{{_ 'add-template-container'}}")
a.board-list-item.label.is-template-container(title="{{_ 'add-template-container'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-template-container'}}
@@ -188,6 +199,11 @@ template(name="boardList")
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
//- #2220: the Home board (opened after login) is badged with a
home icon at the top centre of its tile.
if isDefaultBoard
span.board-home-badge(title="{{_ 'home-board-badge'}}")
i.fa.fa-home
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
@@ -197,14 +213,19 @@ template(name="boardList")
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
+viewer
= title
//- #5174 / #4825: the per-board allows* flags are
//- resolved server-side into the one-shot tile data
//- (the raw fields are not even published to this
//- page), so the show* helpers below gate the whole
//- block consistently for every board.
unless currentSetting.hideBoardMemberList
if allowsBoardMemberList
if showBoardMemberList _id
.minicard-members
each member in boardMembers _id
a.name
+userAvatar(userId=member noRemove=true)
unless currentSetting.hideCardCounterList
if allowsCardCounterList
if showCardCounterList _id
.minicard-lists.flex.flex-wrap
each list in boardLists _id
.item
@@ -223,6 +244,8 @@ template(name="boardList")
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
//- #2220 Home-board toggle is disabled: the icon and the
//- set-default-board action are intentionally not rendered.
template(name="boardsSortPopup")
ul.pop-over-list
+149 -32
View File
@@ -6,6 +6,10 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import {
isDragReorderEnabled,
computeReorderedSortIndex,
} from '/models/lib/boardSortReorder';
// SubsManager removed for Meteor 3 migration
@@ -59,6 +63,15 @@ const DEFAULT_WORKSPACE_ICON = '📁';
// Matches the Admin Panel > People page size.
const BOARDS_PER_PAGE = 25;
// #6439: the effective All Boards sort mode for the current user, defaulting to
// 'custom' (manual drag order) when unknown. Used to gate board drag-reordering.
function currentAllBoardsSortBy() {
const cu = ReactiveCache.getCurrentUser();
return cu && typeof cu.getAllBoardsSortBy === 'function'
? cu.getAllBoardsSortBy()
: 'custom';
}
function getCurrentWorkspacesTree() {
const currentUser = ReactiveCache.getCurrentUser();
const tree =
@@ -119,16 +132,6 @@ function saveWorkspace(workspaceId, { name, icon }) {
}
Template.boardList.helpers({
hideCardCounterList() {
/* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
return Utils.isMiniScreen() && Session.get('currentBoard'); */
return true;
},
hideBoardMemberList() {
/* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
return Utils.isMiniScreen() && Session.get('currentBoard'); */
return true;
},
BoardMultiSelection() {
return BoardMultiSelection;
},
@@ -180,6 +183,15 @@ Template.boardList.onCreated(function () {
Meteor.call('getMyShareableGroups', (err, res) => {
if (!err && res) this.shareableGroups.set(res);
});
// #5174 / #4825: per-board tile data (per-list card counts + member ids) for
// the All Boards tiles, computed server-side and fetched exactly ONCE here —
// deliberately NOT inside an autorun and NOT from reactive getLists/getCards
// cursors, so the #4214 "icons random dance" (a reactive re-render loop on
// this page, which is why the old helpers were stubbed out) cannot return.
this.boardTileDataVar = new ReactiveVar({});
Meteor.call('getAllBoardsTileData', (err, res) => {
if (!err && res) this.boardTileDataVar.set(res);
});
let currUser = ReactiveCache.getCurrentUser();
let userLanguage;
if (currUser && currUser.profile) {
@@ -530,6 +542,21 @@ Template.boardList.helpers({
const boards = ReactiveCache.getBoards(query, {});
const currentUser = ReactiveCache.getCurrentUser();
// #2220: the Home board (opened after login) always appears FIRST in the
// Starred view, even when it has not been explicitly starred.
const withHomeFirst = (arr) => {
if (tpl.selectedMenu.get() !== 'starred') return arr;
if ((tpl.boardSearchVar.get() || '').trim()) return arr;
const homeId =
currentUser && typeof currentUser.getDefaultBoardId === 'function'
? currentUser.getDefaultBoardId()
: null;
if (!homeId) return arr;
const homeBoard = ReactiveCache.getBoard(homeId);
if (!homeBoard || homeBoard.archived) return arr;
return [homeBoard, ...arr.filter((b) => b && b._id !== homeId)];
};
// #5799: in a sorted (non-custom) mode the server already computed the
// current page (filtered by menu/search and sorted), so render exactly that
// ordered page of board icons. Custom (manual drag order) falls through to
@@ -540,9 +567,11 @@ Template.boardList.helpers({
: 'custom';
if (sortMode !== 'custom') {
const paged = tpl.pagedBoardsVar.get();
return (paged.ids || [])
.map((id) => ReactiveCache.getBoard(id))
.filter(Boolean);
return withHomeFirst(
(paged.ids || [])
.map((id) => ReactiveCache.getBoard(id))
.filter(Boolean),
);
}
let list = boards;
@@ -579,37 +608,49 @@ Template.boardList.helpers({
}
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
return currentUser.sortBoardsForUser(list);
return withHomeFirst(currentUser.sortBoardsForUser(list));
}
return list
.slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
return withHomeFirst(
list.slice().sort((a, b) => (a.title || '').localeCompare(b.title || '')),
);
},
// #5174 / #4825: the board tiles' per-list card-count line and member avatar
// row. Data comes from the one-shot getAllBoardsTileData method fetch in
// onCreated (see there) — NOT from reactive getLists/getCards cursors, which
// caused the #4214 "icons random dance" and got these helpers stubbed to []
// (hiding the counters/avatars for everyone). The show* helpers resolve the
// per-board opt-in flags (allowsCardCounterList / allowsBoardMemberList,
// board sidebar "Show at All Boards page"), strictly, server-side — so a
// board with the checkbox off (or never set) consistently shows nothing.
boardLists(boardId) {
/* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
const lists = ReactiveCache.getLists({ 'boardId': boardId, 'archived': false },{sort: ['sort','asc']});
const ret = lists.map(list => {
let cardCount = ReactiveCache.getCards({ 'boardId': boardId, 'listId': list._id }).length;
return `${list.title}: ${cardCount}`;
});
return ret;
*/
return [];
const tile = (Template.instance().boardTileDataVar.get() || {})[boardId];
return (tile && tile.showLists && tile.lists) || [];
},
boardMembers(boardId) {
/* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
const lists = ReactiveCache.getBoard(boardId)
const boardMembers = lists?.members.map(member => member.userId);
return boardMembers;
*/
return [];
const tile = (Template.instance().boardTileDataVar.get() || {})[boardId];
return (tile && tile.showMembers && tile.memberIds) || [];
},
showCardCounterList(boardId) {
const tile = (Template.instance().boardTileDataVar.get() || {})[boardId];
return !!(tile && tile.showLists && tile.lists && tile.lists.length);
},
showBoardMemberList(boardId) {
const tile = (Template.instance().boardTileDataVar.get() || {})[boardId];
return !!(tile && tile.showMembers && tile.memberIds && tile.memberIds.length);
},
isStarred() {
const user = ReactiveCache.getCurrentUser();
return user && user.hasStarred(this._id);
},
// #2220: is this the user's Home board (the one opened after login)?
isDefaultBoard() {
const user = ReactiveCache.getCurrentUser();
return user && user.isDefaultBoard(this._id);
},
isAdministrable() {
const user = ReactiveCache.getCurrentUser();
return user && user.isBoardAdmin(this._id);
@@ -897,6 +938,29 @@ Template.boardList.events({
Meteor.call('toggleBoardStar', boardId);
}
},
// "Selected:" star action: star every multi-selected board that isn't yet
// starred (so it becomes Starred, never toggled back off by this button).
'click .js-star-selected'(evt) {
evt.preventDefault();
evt.stopPropagation();
const user = ReactiveCache.getCurrentUser();
BoardMultiSelection.getSelectedBoardIds().forEach((id) => {
if (user && !user.hasStarred(id)) {
Meteor.call('toggleBoardStar', id);
}
});
},
// #2220 "Selected:" home action: set the first selected board as the Home
// board (opened after login). Clicking it again when it is already Home clears
// it (toggle), matching toggleDefaultBoard.
'click .js-home-selected'(evt) {
evt.preventDefault();
evt.stopPropagation();
const ids = BoardMultiSelection.getSelectedBoardIds();
if (ids.length) {
Meteor.call('toggleDefaultBoard', ids[0]);
}
},
// HTML5 DnD from boards to spaces
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
'dragover .js-share-target'(evt) {
@@ -973,6 +1037,55 @@ Template.boardList.events({
document.querySelectorAll('.workspace-node.board-drag-hint, .js-select-menu.board-drag-hint').forEach((el) => {
el.classList.remove('board-drag-hint');
});
document.querySelectorAll('.js-board.board-reorder-over').forEach((el) => {
el.classList.remove('board-reorder-over');
});
},
// #6439: reorder boards on the All Boards page by dropping one board onto
// another. Only active in the "custom" (manual drag order) sort mode — in the
// sorted modes the order comes from the title, so a board is not a valid drop
// target (which is why, before this, the browser showed the not-allowed cursor
// and nothing persisted). The reorder decision/index math lives in the pure,
// unit-tested models/lib/boardSortReorder.js.
'dragover .js-board'(evt, tpl) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
// A multi-board drag is a share/assign gesture, not a reorder.
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
evt.preventDefault();
evt.stopPropagation();
if (dt) dt.dropEffect = 'move';
evt.currentTarget.classList.add('board-reorder-over');
},
'dragleave .js-board'(evt) {
evt.currentTarget.classList.remove('board-reorder-over');
},
'drop .js-board'(evt, tpl) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
const draggedId = dt ? dt.getData('text/plain') : '';
if (!draggedId) return;
evt.preventDefault();
evt.stopPropagation();
evt.currentTarget.classList.remove('board-reorder-over');
const targetId = this._id;
// #6442: current display order of the board grid. Read each board's _id from
// its Blaze data context (the same source dragstart uses via `this._id`) —
// NOT el.classList[0]. On `li.js-board(class="{{_id}} …")` Jade emits the
// literal `js-board` class FIRST, so classList[0] is the string "js-board"
// for every board; the old code therefore produced ['js-board','js-board',…],
// computeReorderedSortIndex could not find the dragged/target ids among them,
// returned null, and the drop silently did nothing (board snapped back).
const orderedIds = Array.from(tpl.findAll('.js-board'))
.map((el) => Blaze.getData(el)?._id)
.filter(Boolean);
const mapping = computeReorderedSortIndex(orderedIds, draggedId, targetId);
if (!mapping) return;
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser && typeof currentUser.setBoardSortIndexes === 'function') {
currentUser.setBoardSortIndexes(mapping);
}
},
'click .js-clone-board'(evt) {
if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
@@ -1035,6 +1148,10 @@ Template.boardList.events({
},
'click .js-multiselection-reset'(evt) {
evt.preventDefault();
// The reset X is now nested inside the activate button (matching the Swimlanes
// view), so stop the click from bubbling to js-multiselection-activate — which
// would otherwise immediately re-activate what we just disabled.
evt.stopPropagation();
BoardMultiSelection.disable();
},
'click .js-toggle-board-multi-selection'(evt) {
+55
View File
@@ -0,0 +1,55 @@
/* Board "Statistics" view — full width, like the other board views. Its text is
selectable so any value can be highlighted and copied. */
.stats-view {
width: 100%;
height: 100%;
overflow: auto;
user-select: text;
-webkit-user-select: text;
}
.stats-view-content {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.stats-view-title {
margin: 0 0 16px;
font-size: 22px;
font-weight: bold;
}
.stats-view-section {
margin-bottom: 28px;
}
.stats-view-heading {
margin: 0 0 8px;
font-size: 16px;
font-weight: bold;
border-bottom: 2px solid #dbdbdb;
padding-bottom: 4px;
}
.stats-view-table {
width: 100%;
border-collapse: collapse;
}
.stats-view-table td {
padding: 8px 4px;
border-bottom: 1px solid #eee;
user-select: text;
-webkit-user-select: text;
}
.stats-view-table .stats-view-label {
color: #666;
width: 60%;
}
.stats-view-table .stats-view-value {
font-weight: bold;
text-align: right;
}
+45
View File
@@ -0,0 +1,45 @@
template(name="statsView")
.stats-view
.stats-view-content
h1.stats-view-title {{ boardTitle }}
.stats-view-section
h2.stats-view-heading {{_ 'board-status'}}
table.stats-view-table
tbody
tr
td.stats-view-label {{_ 'board-status-loading-mode'}}
td.stats-view-value {{ loadingModeLabel }}
tr
td.stats-view-label {{_ 'swimlanes'}}
td.stats-view-value {{ swimlaneCount }}
tr
td.stats-view-label {{_ 'lists'}}
td.stats-view-value {{ listCount }}
tr
td.stats-view-label {{_ 'cards'}}
td.stats-view-value {{ cardCount }}
tr
td.stats-view-label {{_ 'card-archived'}}
td.stats-view-value {{ archivedCardCount }}
tr
td.stats-view-label {{_ 'labels'}}
td.stats-view-value {{ labelCount }}
tr
td.stats-view-label {{_ 'members'}}
td.stats-view-value {{ memberCount }}
tr
td.stats-view-label {{_ 'custom-fields'}}
td.stats-view-value {{ customFieldCount }}
.stats-view-section
h2.stats-view-heading {{_ 'board-status-time-summary'}}
table.stats-view-table
tbody
tr
td.stats-view-label {{_ 'board-status-time-spent-total'}}
td.stats-view-value {{ timeSpentTotal }}
tr
td.stats-view-label {{_ 'board-status-cards-with-time'}}
td.stats-view-value {{ cardsWithTimeSpent }}
tr
td.stats-view-label {{_ 'board-status-overtime-cards'}}
td.stats-view-value {{ overtimeCards }}
+70
View File
@@ -0,0 +1,70 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { ReactiveVar } from 'meteor/reactive-var';
import { Utils } from '/client/lib/utils';
// Board view "Statistics" (Tilastot) — a full-width board view (like Swimlanes /
// Lists / Calendar / Gantt / Table) showing the board's status: its card-loading
// mode, counts (swimlanes, lists, cards, labels, members, custom fields) and a
// time-spent summary. Counts come from the server `boardStatus` method so they are
// accurate even in lazy mode, where the browser only holds the visible card window.
Template.statsView.onCreated(function() {
this.status = new ReactiveVar(null);
this.autorun(() => {
const boardId = Session.get('currentBoard');
if (boardId) {
Meteor.call('boardStatus', boardId, (err, res) => {
if (!err && res) this.status.set(res);
});
}
});
});
// Read one numeric field from the resolved status, or a placeholder while loading.
function statsNum(key) {
const s = Template.instance().status.get();
return s ? (s[key] || 0) : '…';
}
Template.statsView.events({
// The board canvas uses drag-to-scroll (the `dragscroll` class), which otherwise
// swallows pointer/touch drags and prevents selecting text. Stop these events from
// reaching it so the browser's native selection works — with the mouse (drag) and
// on touch (long-press) — and any value can be copied to the clipboard.
'mousedown .stats-view'(event) {
event.stopPropagation();
},
'touchstart .stats-view'(event) {
event.stopPropagation();
},
});
Template.statsView.helpers({
boardTitle() {
const board = Utils.getCurrentBoard();
return board ? board.title : '';
},
loadingModeLabel() {
const s = Template.instance().status.get();
if (!s) return '…';
const inEffect = s.lazy
? TAPi18n.__('cards-loading-lazy')
: TAPi18n.__('cards-loading-all');
return s.mode === 'auto' ? `${inEffect} (${TAPi18n.__('cards-loading-auto')})` : inEffect;
},
swimlaneCount() { return statsNum('swimlanes'); },
listCount() { return statsNum('lists'); },
cardCount() { return statsNum('cards'); },
archivedCardCount() { return statsNum('archivedCards'); },
labelCount() { return statsNum('labels'); },
memberCount() { return statsNum('members'); },
customFieldCount() { return statsNum('customFields'); },
cardsWithTimeSpent() { return statsNum('cardsWithTimeSpent'); },
overtimeCards() { return statsNum('overtimeCards'); },
timeSpentTotal() {
const s = Template.instance().status.get();
if (!s) return '…';
const n = Number(s.timeSpentTotal) || 0;
return `${n} h`;
},
});
-12
View File
@@ -63,18 +63,6 @@
color: #333;
}
/* Sortable column headers (Excel-like): clickable, with a sort-direction
arrow appended by the sortIndicator helper. */
.my-cards-board-table th.js-table-view-sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.my-cards-board-table th.js-table-view-sort:hover {
color: #01628c;
}
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
on the Admin Panel / People page, opening the card when clicked. */
.my-cards-board-table td.table-view-cell-edit {
+8 -8
View File
@@ -18,21 +18,21 @@ template(name="tableView")
thead
tr
th(scope="col")
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
th(scope="col") {{_ "card"}}
th(scope="col") {{_ "list"}}
th(scope="col") {{_ "swimlane"}}
unless isMiniScreen
th(scope="col") {{_ "assignees"}}
th(scope="col") {{_ "members"}}
th(scope="col") {{_ "labels"}}
if showReceivedColumn
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
th(scope="col") {{_ "card-received"}}
if showStartColumn
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
th(scope="col") {{_ "card-start"}}
if showDueColumn
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
th(scope="col") {{_ "card-due"}}
if showEndColumn
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
th(scope="col") {{_ "card-end"}}
tbody
each row in rows
tr
@@ -66,7 +66,7 @@ template(name="tableView")
td.table-view-cell-labels
.div
each label in row.labels
span.card-label(class="card-label-{{label.color}}" title="{{label.name}}")
span.card-label(class="{{labelColorClass label.color}}" style="{{labelColorStyle label.color}}" title="{{label.name}}")
+viewer
= label.name
if showReceivedColumn
+5 -57
View File
@@ -14,8 +14,6 @@ const rowsPerPage = 25;
Template.tableView.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.filteredRows = new ReactiveVar([]);
@@ -30,8 +28,6 @@ Template.tableView.onCreated(function () {
}
const query = this.searchQuery.get().trim().toLowerCase();
const field = this.sortField.get();
const direction = this.sortDirection.get();
const rows = [];
board.cards().forEach(card => {
@@ -75,39 +71,11 @@ Template.tableView.onCreated(function () {
});
}
// Map a date sort field to the matching row property.
const dateFieldProp = {
received: 'receivedAt',
start: 'startAt',
due: 'dueAt',
end: 'endAt',
};
filtered = filtered.slice().sort((a, b) => {
const dateProp = dateFieldProp[field];
if (dateProp) {
// Cards without the date sort last, regardless of direction.
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
return (av - bv) * direction;
}
let av;
let bv;
if (field === 'list') {
av = a.listTitle;
bv = b.listTitle;
} else if (field === 'swimlane') {
av = a.swimlaneTitle;
bv = b.swimlaneTitle;
} else {
av = a.title;
bv = b.title;
}
return (
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
direction
);
});
// Fixed order: by card title, ascending. (Column-header click-to-sort was
// removed; the Table view now always shows this stable order.)
filtered = filtered.slice().sort((a, b) =>
a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }),
);
this.filteredRows.set(filtered);
});
@@ -172,13 +140,6 @@ Template.tableView.helpers({
const board = Utils.getCurrentBoard();
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
},
// Excel-like sort arrow shown on the active sort column header.
sortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.tableView.events({
@@ -212,19 +173,6 @@ Template.tableView.events({
if (current < totalPages) tpl.page.set(current + 1);
},
'click .js-table-view-sort'(event, tpl) {
event.preventDefault();
const field = event.currentTarget.dataset.sort;
if (!field) return;
if (tpl.sortField.get() === field) {
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
} else {
tpl.sortField.set(field);
tpl.sortDirection.set(1);
}
tpl.page.set(1);
},
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
// Board Table view (same mechanism as opening a card from search results).
'click .js-table-view-edit-card'(event) {
+8 -9
View File
@@ -62,20 +62,20 @@ template(name="attachmentGallery")
.attachment-thumbnail-container.open-preview(data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
if link
if(isImage)
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}")
img.attachment-thumbnail(src="{{link}}" title="{{cleanFilename name}}")
else if($eq extension 'svg')
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}" type="image/svg+xml")
img.attachment-thumbnail(src="{{link}}" title="{{cleanFilename name}}" type="image/svg+xml")
else if($eq extension 'mp3')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="audio/mpeg")
else if($eq extension 'ogg')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="video/ogg")
else if($eq extension 'webm')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="video/webm")
else if($eq extension 'mp4')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="video/mp4")
else
span.attachment-thumbnail-text= extension
@@ -83,15 +83,14 @@ template(name="attachmentGallery")
.attachment-details-container
.attachment-details
div
b
= name
b {{ cleanFilename name }}
span.file-size ({{fileSize size}})
if uploaderName
div.attachment-uploader {{uploaderName}}
if uploadedAt
div.attachment-uploaded-at {{uploadedAt}}
.attachment-actions
a.js-download(href="{{link}}?download=true", download="{{name}}", title="{{_ 'download'}}")
a.js-download(href="{{link}}?download=true", download="{{downloadFilename name}}", title="{{_ 'download'}}")
i.fa.fa-arrow-down
if currentUser.isBoardMember
unless currentUser.isCommentOnly
+25 -8
View File
@@ -6,6 +6,7 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
import { formatDateTime } from '/imports/lib/dateUtils';
import { EscapeActions } from '/client/lib/escapeActions';
@@ -49,7 +50,14 @@ Template.attachmentGallery.events({
if (card && card.coverId === this._id) {
await card.unsetCover();
}
await Attachments.removeAsync(this._id);
// #5282 (same class as #3252 for comments/checklists): only remove if the
// doc is still in the local cache. Under publication churn the attachment
// can already be evicted from Minimongo, and removing a missing _id
// throws "Removed nonexistent document" even though the delete itself
// succeeded on the server.
if (this._id && ReactiveCache.getAttachment(this._id)) {
await Attachments.removeAsync(this._id);
}
Popup.back();
}),
});
@@ -141,7 +149,9 @@ function openAttachmentViewer(attachmentId) {
break;
}
$('#attachment-name').text(attachment.name);
// Show the cleaned name: URL-decoded, homoglyphs folded, invisible characters
// and exploit markup removed (plain text; .text() escapes, so never HTML).
$('#attachment-name').text(cleanFileName(attachment.name));
$('#viewer-overlay').removeClass('hidden');
}
@@ -432,11 +442,16 @@ export async function handleFileUpload(card, files) {
fileName: fileName,
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
// Use HTTP transport instead of DDP so file chunks go over a dedicated
// fetch POST rather than flooding the WebSocket/DDP channel. DDP
// upload causes repeated DDP reconnects in Safari, showing the
// "Loading, please wait" offline banner and stalling progress at ~95%.
transport: 'http',
// Use HTTP transport (a dedicated fetch POST per chunk) rather than DDP,
// which floods the WebSocket channel and causes repeated reconnects in
// Safari (the "Loading, please wait" offline banner, stalling at ~95%).
// EXCEPT on Sandstorm: the grain's sandstorm-http-bridge strips Meteor-Files'
// custom upload headers — x-start/x-mtok/x-chunkid/x-fileid/x-eof are not on
// Sandstorm's request-header whitelist — so the server never sees x-start,
// treats every request as a chunk continuation, and fails with "Can't
// continue upload, session expired" [408]. DDP uploads go over method calls
// (no custom HTTP headers), so they work through the bridge.
transport: Meteor.settings?.public?.sandstorm ? 'ddp' : 'http',
};
config.meta.fileId = fileId;
@@ -528,7 +543,9 @@ Template.previewClipboardImagePopup.events({
meta: Utils.getCommonAttachmentMetaFrom(card),
fileName: file.name || file.type.replace('image/', 'clipboard.'),
chunkSize: 'dynamic',
transport: 'http',
// DDP on Sandstorm (its bridge strips Meteor-Files' x-* upload headers),
// HTTP elsewhere — see the note in the other upload handler above.
transport: Meteor.settings?.public?.sandstorm ? 'ddp' : 'http',
};
config.meta.fileId = fileId;
const uploader = await Attachments.insertAsync(
@@ -1,3 +1,5 @@
import { UnsavedEdits } from '/client/lib/unsavedEdits';
const descriptionFormIsOpen = new ReactiveVar(false);
Template.descriptionForm.onDestroyed(function () {
@@ -16,6 +18,10 @@ Template.descriptionForm.events({
event.preventDefault();
const description = tpl.currentComponent ? tpl.currentComponent().getValue() : tpl.$('textarea').val();
await this.setDescription(description);
// #6455: a successful save means there is no unsaved draft anymore; clear
// any pre-existing draft record so the "You have an unsaved description"
// warning does not stick around after saving.
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: this._id });
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt, tpl) {
+62 -33
View File
@@ -129,50 +129,82 @@
box-sizing: border-box;
}
/* Desktop mode: position card below board header */
/* Desktop mode: position card below board header.
#6465: the floating window used to span nearly the whole board (insets
20px/20px from BOTH edges, top-left anchored) — right on top of the card it
belongs to, with big rounded borders. Dock it to the END edge (right in LTR)
like the classic 6.09 side panel instead: the board and the card stay
visible, and the drag handle still moves it anywhere. */
body.desktop-mode .card-details:not(.card-details-popup) {
position: fixed;
width: auto;
max-width: 800px;
max-width: 520px; /* #6465: narrower dock (was 800px, ~1/4 screen) */
flex-basis: auto;
border-radius: 8px;
border-radius: 3px;
z-index: 2001;
}
/* Default position for first card or when dragged */
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
top: 8px;
inset-inline-start: 20px;
inset-inline-end: 20px;
bottom: 20px;
inset-inline-start: auto;
inset-inline-end: 8px;
width: min(650px, 90vw);
bottom: 8px;
}
/* Stagger positions for multiple cards using nth-of-type */
/* Stagger positions for multiple cards using nth-of-type (from the end edge) */
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
top: 8px;
inset-inline-start: 20px;
inset-inline-end: 8px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
top: 38px;
inset-inline-start: 50px;
inset-inline-end: 38px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
top: 68px;
inset-inline-start: 80px;
inset-inline-end: 68px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
top: 98px;
inset-inline-start: 110px;
inset-inline-end: 98px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
top: 128px;
inset-inline-start: 140px;
inset-inline-end: 128px;
}
/* For expanded cards, set dimensions */
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
inset-inline-end: 20px;
bottom: 20px;
bottom: 8px;
}
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
with viewport insets), never laid out inside — or measured from — the
scrolled swimlane/board container. Historically the maximized pane was an
in-flow flex/float item of its swimlane, so after scrolling down in the
swimlanes view it rendered at the swimlane's position in the tall scrolled
canvas (off-screen) instead of over the viewport.
All four insets are !important for two reasons:
1. The floating-window rules above (top: 8px / inset-inline-end: 8px /
width: min(650px, 90vw), specificity up to 0,5,1) otherwise override the
lower-specificity .card-details-maximized geometry entirely, leaving the
Maximize button a no-op.
2. The drag handle writes inline left/top styles onto the card; a card that
was dragged and then maximized must not keep the stale dragged offsets
(an off-viewport inline top is exactly the "wrong place" of #4822).
Only !important declarations beat inline styles. */
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
position: fixed !important;
top: 8px !important;
inset-inline-start: 8px !important;
inset-inline-end: 8px !important;
bottom: 8px !important;
width: auto !important;
height: auto !important;
max-width: none !important;
resize: none;
}
/* Collapsed card state - hide content and set height to title row only */
@@ -192,7 +224,9 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-details-canvas {
width: auto;
padding: 0 2.5vw;
/* #6465: fixed 20px as in 6.09 — 2.5vw was ~48px of white border per side
on a FullHD screen. */
padding: 0 20px;
}
.card-details .card-details-header {
margin: 0 -20px 5px;
@@ -300,25 +334,14 @@ body.desktop-mode .card-details.card-details-collapsed {
float: inline-end;
}
/* Unify all card text to match title size */
/* #6465: the "unify all card text to match title size" block (v8.18) forced
EVERY element in card details to the base font size with line-height 1.5,
flattening the 6.09 hierarchy where labels/metadata were smaller than body
text — a big part of the "card window is WAAAY too big" complaint. Removed:
components keep their own sizes again. */
.card-details {
font-size: 1em;
}
.card-details p,
.card-details span,
.card-details div,
.card-details a,
.card-details label,
.card-details input,
.card-details textarea,
.card-details select,
.card-details button,
.card-details .card-details-item-title,
.card-details .card-label,
.card-details .viewer {
font-size: inherit;
line-height: 1.5;
}
.card-details .card-details-header .card-details-watch {
font-size: 17px;
padding-inline-start: 7px;
@@ -422,8 +445,8 @@ body.desktop-mode .card-details.card-details-collapsed {
@media screen and (min-width: 801px) {
.card-details {
top: 97px;
inset-inline-start: calc(50% - (600px / 2));
width: 600px;
inset-inline-start: calc(50% - (520px / 2)); /* #6465: narrower card (was 600px) */
width: 520px;
bottom: 0;
position: fixed;
resize: both;
@@ -446,6 +469,12 @@ body.desktop-mode .card-details.card-details-collapsed {
flex-shrink: 0;
flex-basis: calc(100% - 20px);
will-change: flex-basis;
/* #4822: declare the position strategy explicitly. The maximized pane must
be fixed to the viewport in its own right — never an in-flow flex/float
item of the scrolled swimlane container, and never dependent on the
sibling .card-details rule (or the body.desktop-mode rules) happening to
supply position: fixed. */
position: fixed;
overflow-y: auto;
overflow-x: auto;
background: #f7f7f7;
+12 -8
View File
@@ -6,7 +6,7 @@ template(name="cardDetails")
+attachmentViewer
section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}' class='{{#if cardCollapsed}}card-details-collapsed{{/if}}'): .card-details-canvas
.card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}')
.card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}' style='{{colorStyle}}')
+inlinedForm(classNames="js-card-details-title")
+editCardTitleForm
else
@@ -127,7 +127,7 @@ template(name="cardDetails")
| {{_ 'labels'}}
a(class="{{#if canModifyCard}}js-add-labels{{else}}is-disabled{{/if}}" title="{{_ 'card-labels-title'}}")
each labels
span.card-label(class="card-label-{{color}}" title=name)
span.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+viewer
= name
if canModifyCard
@@ -741,12 +741,12 @@ template(name="cardDetails")
i.fa.fa-history
| {{ _ 'activities'}}
if currentUser.isBoardMember
.material-toggle-switch(title="{{_ 'show-activities'}}")
if showActivities
input.toggle-switch(type="checkbox" id="toggleShowActivitiesCard" checked="checked")
else
input.toggle-switch(type="checkbox" id="toggleShowActivitiesCard")
label.toggle-label(for="toggleShowActivitiesCard")
if showActivities
a.activities-eye-toggle.js-toggle-show-activities-card(title="{{_ 'hide-activities'}}" aria-label="{{_ 'hide-activities'}}")
i.fa.fa-eye.eye-icon
else
a.activities-eye-toggle.js-toggle-show-activities-card(title="{{_ 'show-activities'}}" aria-label="{{_ 'show-activities'}}")
i.fa.fa-eye-slash.eye-slash-icon
if currentUser.isBoardAdmin
if isLoaded.get
@@ -1199,6 +1199,10 @@ template(name="setCardColorPopup")
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
// #5514: native color wheel for picking a custom RGB / hex color
.color-wheel-wrap
label(for="cardColorWheel") {{_ 'custom-color'}}
input.js-card-color-wheel#cardColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
+95 -8
View File
@@ -50,7 +50,10 @@ import Users from '/models/users';
import Lists from '/models/lists';
import CardComments from '/models/cardComments';
import { ALLOWED_COLORS } from '/config/const';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { uniqBy } from '/imports/lib/collectionHelpers';
import { memberTargetBoardId } from '/models/lib/linkedCardMembers';
import { isSubmitKey } from '/models/lib/editorSubmitKey';
import { UserAvatar } from '../users/userAvatar';
import { Filter } from '/client/lib/filter';
import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlaneListCard';
@@ -60,6 +63,7 @@ import {
getCurrentCardIdFromContext,
getCurrentCardFromContext,
} from '/client/lib/currentCard';
import { isLazyCards } from '/client/lib/lazyCards';
import uploadProgressManager from '../../lib/uploadProgressManager';
import { CSSEvents } from '/client/lib/cssEvents';
import { UnsavedEdits } from '/client/lib/unsavedEdits';
@@ -371,6 +375,19 @@ Template.cardDetails.onCreated(function () {
Meteor.subscribe('legacyBoardAttachments', board._id);
}
});
// In lazy (windowed) card loading the board/window publications only ship the
// children of cards in the visible window, so an open card that scrolled out of
// — or was just added to — a window could miss its own comments/attachments/
// checklists. Subscribe to the open card's OWN live children so the card detail
// is always complete and reactive, independent of the window (#6480). No-op in
// 'all' mode, where the board publication already ships everything.
this.autorun(() => {
const cardId = Session.get('currentCard');
if (cardId && isLazyCards()) {
Meteor.subscribe('openCardData', cardId);
}
});
});
Template.cardDetails.onRendered(function () {
@@ -864,6 +881,12 @@ Template.cardDetails.events({
// permission denial that otherwise vanished with no feedback.
try {
await card.setDescription(description);
// #6455: a successful save means there is no unsaved draft anymore.
// Clear the record explicitly instead of relying on the inlined form's
// close-time draft/description comparison, which can run before the
// updated description is visible and leave a stale "You have an unsaved
// description" warning behind.
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: card._id });
} catch (error) {
alert(error?.reason || error?.message || 'Failed to save description');
}
@@ -1045,7 +1068,7 @@ Template.cardDetails.events({
const card = Template.currentData();
await card.toggleHideCheckedChecklistItems();
},
async 'change #toggleShowActivitiesCard'() {
async 'click .js-toggle-show-activities-card'() {
const card = Template.currentData();
await card.toggleShowActivities();
},
@@ -1364,10 +1387,24 @@ Template.inlinedCardDescription.onCreated(function () {
this._close = (isReset = false) => {
if (this.isOpen.get() && !isReset) {
const draft = (this._getValue() || '').trim();
// #6455: normalize BOTH sides the same way before comparing. The textarea
// value has per-line trailing whitespace stripped (_getValue), while
// getDescription() returns the raw stored text — or null for an empty
// description. Comparing them raw made the comparison permanently unequal
// for descriptions containing Markdown " \n" hard breaks (or after
// emptying the description), re-creating the phantom draft on every save.
const normalize = (s) => (s || '').replaceAll(/[ \f\r\t\v]+$/gm, '').trim();
const draft = normalize(this._getValue());
const card = getCurrentCardFromContext();
if (card && draft !== card.getDescription()) {
if (card && draft !== normalize(card.getDescription())) {
UnsavedEdits.set(this._getUnsavedEditKey(), this._getValue());
} else {
// #6455: closing with the draft equal to the saved description must
// REMOVE any pre-existing unsaved-edits record. Previously this branch
// did nothing, so once a draft record existed, pressing Save ("View it"
// -> Save) could never clear the "You have an unsaved description"
// warning — only Discard could.
UnsavedEdits.reset(this._getUnsavedEditKey());
}
}
this.isOpen.set(false);
@@ -1395,7 +1432,10 @@ Template.inlinedCardDescription.events({
tpl.isOpen.set(true);
},
'keydown form textarea'(evt, tpl) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
// Ctrl/Cmd+Enter submits by default; plain Enter when the user enabled
// "submit on Enter" in Member Settings (Shift+Enter then makes a newline).
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
if (isSubmitKey(evt, { submitOnEnter })) {
tpl.find('button[type=submit]').click();
}
},
@@ -1464,6 +1504,15 @@ Template.cardDetailsActionsPopup.events({
Popup.close();
if (!card) return;
await card.archive();
// #6465 follow-up: archiving must also CLOSE the card's details window.
// The card id stayed in the openCards session list, so the floating window
// kept rendering above the board — and, docked to the right edge, exactly
// over the archives sidebar someone restoring the card would open next,
// intercepting its Restore/Delete clicks.
Session.set('openCards', (Session.get('openCards') || []).filter((id) => id !== card._id));
if (Session.get('currentCard') === card._id) {
Session.set('currentCard', null);
}
Utils.goBoardId(card.boardId);
}),
'click .js-more': Popup.open('cardMore'),
@@ -1497,9 +1546,12 @@ Template.editCardTitleForm.events({
Utils.showCopied(promise, $tooltip);
},
'keydown .js-edit-card-title'(event) {
// If enter key was pressed, submit the data
// Unless the shift key is also being pressed
if (event.keyCode === 13 && !event.shiftKey) {
// #4236: by default Ctrl/Cmd+Enter saves; plain Enter (and Shift+Enter)
// insert a new line. A user who turns on "submit on Enter" in Member
// Settings gets plain Enter to save (Shift+Enter for a newline) instead —
// the pre-#4236 fast title-editing workflow (#6480 email feedback).
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
if (isSubmitKey(event, { submitOnEnter })) {
$('.js-submit-edit-card-title-form').click();
}
},
@@ -1657,7 +1709,25 @@ Template.cardLocationsPopup.events({
});
const filterMembers = (filterTerm) => {
let currBoard = Utils.getCurrentBoard();
// #5676: for a linked card the members live on the REAL card it points at,
// which may be on another board (that is what getMembers()/toggleMember() act
// on). Offer that board's members instead of the viewing board's, so the
// picker is consistent with where membership is actually stored. Falls back to
// the current board for normal cards or when the real card isn't loaded yet.
const card = getCurrentCardFromContext();
let currBoard = null;
if (card) {
const boardId = memberTargetBoardId(card, id => ReactiveCache.getCard(id));
if (boardId) {
currBoard = ReactiveCache.getBoard(boardId);
}
}
if (!currBoard) {
currBoard = Utils.getCurrentBoard();
}
if (!currBoard) {
return [];
}
let members = currBoard.activeMembers();
if (filterTerm) {
@@ -2013,6 +2083,10 @@ Template.setCardColorPopup.helpers({
}
return tpl.currentColor.get() === color;
},
// #5514: current color as a '#rrggbb' hex for the native color wheel <input>.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#0079bf';
},
});
Template.setCardColorPopup.events({
@@ -2020,6 +2094,13 @@ Template.setCardColorPopup.events({
const paletteData = Blaze.getData(event.currentTarget);
tpl.currentColor.set(paletteData?.color);
},
// #5514: picking from the native color wheel stores a custom hex.
'input .js-card-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
async 'click .js-submit'(event, tpl) {
event.preventDefault();
const card = Cards.findOne(getCardId());
@@ -2183,6 +2264,12 @@ Template.cardMorePopup.events({
// verify that there are no linked cards
if (ReactiveCache.getCards({ linkedId: card._id }).length === 0) {
Cards.remove(card._id);
// #6465 follow-up: like archiving, deleting must close the card's
// details window (drop it from the openCards session list).
Session.set('openCards', (Session.get('openCards') || []).filter((id) => id !== card._id));
if (Session.get('currentCard') === card._id) {
Session.set('currentCard', null);
}
} else {
// TODO: Maybe later we can list where the linked cards are.
// Now here is popup with a hint that the card cannot be deleted
+78
View File
@@ -1,3 +1,81 @@
/* #4759: Member Settings / Font popup — a button per installed font (each shown in
its own font) and a row of size buttons (smaller ... 100% ... larger). Clicking a
button applies immediately. */
.ui-font-buttons,
.ui-font-size-buttons {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 2px 0 10px 0;
}
.ui-font-btn,
.ui-font-size-btn {
display: inline-block;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
text-decoration: none;
}
.ui-font-btn:hover,
.ui-font-size-btn:hover {
background: #f0f0f0;
}
.ui-font-btn.active,
.ui-font-size-btn.active {
background: #2980b9;
border-color: #2980b9;
color: #fff;
}
/* The "Unset" colour reset links look like buttons. */
.color-wheel-wrap .js-reset-text-color,
.color-wheel-wrap .js-reset-bg-color {
display: inline-block;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
text-decoration: none;
}
.color-wheel-wrap .js-reset-text-color:hover,
.color-wheel-wrap .js-reset-bg-color:hover {
background: #f0f0f0;
}
.color-wheel-wrap .js-reset-text-color.disabled,
.color-wheel-wrap .js-reset-bg-color.disabled {
opacity: 0.5;
pointer-events: none;
}
/* #5514: native color-wheel (custom RGB / hex) picker wrapper, shared by the
label / list / swimlane / card color popups. */
.color-wheel-wrap {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 10px 0;
}
.color-wheel-wrap label {
margin: 0;
}
.color-wheel-wrap input[type="color"] {
width: 44px;
height: 28px;
padding: 0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: none;
}
.card-label {
border: 1px solid #000;
border-radius: 4px;
+6 -3
View File
@@ -4,9 +4,12 @@ template(name="formLabel")
label {{_ "select-color"}}
.palette-colors: each labels
span.card-label.palette-color.js-palette-color(class="card-label-{{color}}")
span.card-label.palette-color.js-palette-color(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
if(isSelected color)
i.fa.fa-check
.color-wheel-wrap
label(for="labelColorWheel") {{_ 'custom-color'}}
input.js-label-color-wheel#labelColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
template(name="createLabelPopup")
form.create-label
@@ -31,8 +34,8 @@ template(name="cardLabelsPopup")
a.card-label-edit-button.fa.fa-pencil.js-edit-label
if isTouchScreenOrShowDesktopDragHandles
span.fa.label-handle(class="fa-arrows" title="{{_ 'dragLabel'}}")
span.card-label.card-label-selectable.js-select-label.card-label-wrapper(class="card-label-{{color}}"
class="{{# if isLabelSelected ../_id }}active{{/if}}")
span.card-label.card-label-selectable.js-select-label.card-label-wrapper(class="{{labelColorClass color}}"
class="{{# if isLabelSelected ../_id }}active{{/if}}" style="{{labelColorStyle color}}")
+viewer
= name
if(isLabelSelected ../_id)
+33 -2
View File
@@ -1,5 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { LABEL_COLORS } from '/models/metadata/colors';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
@@ -28,6 +29,11 @@ Template.formLabel.helpers({
isSelected(color) {
return Template.instance().currentColor.get() === color;
},
// #5514: current color as a '#rrggbb' hex for the color-wheel <input>, which
// only accepts hex. Named colors map to their palette hex; fall back to green.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#3cb500';
},
});
Template.formLabel.events({
@@ -36,6 +42,13 @@ Template.formLabel.events({
const selectedColor = paletteData?.color || Template.currentData()?.color || getFallbackLabelColor();
tpl.currentColor.set(selectedColor);
},
// #5514: picking a color from the native color wheel stores a custom hex.
'input .js-label-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
});
Template.createLabelPopup.helpers({
@@ -131,7 +144,16 @@ Template.createLabelPopup.events({
? selectedColorIcon.closest('.js-palette-color')
: null;
const selectedColorData = selectedPaletteNode && Blaze.getData(selectedPaletteNode);
const color = selectedColorData?.color || getFallbackLabelColor();
// #5514: a named swatch (has the check icon) wins; otherwise fall back to
// the custom color-wheel hex, then to the default palette color.
let color = selectedColorData?.color;
if (!color) {
const wheel = templateInstance.find('.js-label-color-wheel');
if (wheel && isHexColor(wheel.value)) {
color = wheel.value;
}
}
color = color || getFallbackLabelColor();
board.addLabel(name, color);
Popup.back();
},
@@ -158,7 +180,16 @@ Template.editLabelPopup.events({
? selectedColorIcon.closest('.js-palette-color')
: null;
const selectedColorData = selectedPaletteNode && Blaze.getData(selectedPaletteNode);
const color = selectedColorData?.color || getFallbackLabelColor();
// #5514: a named swatch (has the check icon) wins; otherwise fall back to
// the custom color-wheel hex, then to the default palette color.
let color = selectedColorData?.color;
if (!color) {
const wheel = templateInstance.find('.js-label-color-wheel');
if (wheel && isHexColor(wheel.value)) {
color = wheel.value;
}
}
color = color || getFallbackLabelColor();
board.editLabel(this._id, name, color);
Popup.back();
},
+17
View File
@@ -183,6 +183,13 @@
}
.minicard .minicard-title {
margin-inline-end: 1.5vw;
/* #6444: isolate each title's bidirectional context. The title is a
dir="auto" .viewer; under a global dir="rtl" document, non-isolated
dir="auto" runs share bidi state, so typing a strong RTL (e.g. Arabic)
character into another list's dir="auto" composer re-resolved the shared
context and visibly reflowed OTHER lists' minicard titles (no data change,
reverts on refresh). Isolation makes each title a self-contained run. */
unicode-bidi: isolate;
}
.minicard .minicard-title .card-number {
color: #b3b3b3;
@@ -196,6 +203,12 @@
.minicard .minicard-title .viewer {
display: block;
word-wrap: break-word;
/* #6465: the minicard title renders inside a .viewer, which inherits the
global `.viewer { min-height: 2.5vh }` (layouts.css). On tall screens that
reserves a big empty band under a one-line title, so half of every
minicard was white space. Collapse it to fit the text (same fix already
applied to the label .viewer above). */
min-height: 0;
}
}
.minicard .dates {
@@ -448,6 +461,10 @@
min-height: 50px;
margin-bottom: 20px;
overflow-y: auto;
/* #6444: isolate the composer's dir="auto" bidi run so typing a strong RTL
character here cannot perturb the rendering of the surrounding minicard
titles under a dir="rtl" document. */
unicode-bidi: isolate;
}
.parent-prefix {
color: #b3b3b3;
+4 -3
View File
@@ -3,7 +3,8 @@ template(name="minicard")
class="{{#if isLinkedCard}}linked-card{{/if}}"
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{agingClass}}"
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}"
style="{{colorStyle}}")
if canMoveCard
if isTouchScreenOrShowDesktopDragHandles
.handle
@@ -93,11 +94,11 @@ template(name="minicard")
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels
unless hiddenMinicardLabelText
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
span.js-card-label.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+viewer
= name
if hiddenMinicardLabelText
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title="{{name}}")
.minicard-custom-fields
each customFieldsWD
+49 -17
View File
@@ -6,6 +6,11 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
import Cards from '/models/cards';
import { resolveCoverId } from '/models/lib/linkedCardCover';
import {
parseChecklistItemTitles,
buildChecklistItemPayload,
} from '/models/lib/checklistItemTitles';
import { normalizeDependencies } from '/models/metadata/dependencies';
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
@@ -164,8 +169,12 @@ Template.minicard.helpers({
}
},
cover() {
if (!this.coverId) return null;
const attachment = ReactiveCache.getAttachment(this.coverId);
// #5666: for a linked card the cover lives on the real card it points at, so
// resolve the cover id through it (mirroring getTitle/getDue/...); a plain
// card resolves to its own coverId.
const coverId = resolveCoverId(this, id => ReactiveCache.getCard(id));
if (!coverId) return null;
const attachment = ReactiveCache.getAttachment(coverId);
if (!attachment) return null;
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
if (!coverLink) return null;
@@ -376,6 +385,28 @@ Template.minicardChecklist.helpers({
},
});
// #6440: create the checklist item(s) for the minicard add-item form. Shared by
// the Save-button click handler and the (rarely reached) form-submit handler.
// The Save button needs an explicit click handler because the minicard — and so
// the add-item <form> — is rendered inside the `a.minicard-wrapper` anchor, where
// the native form `submit` event never reaches the Blaze event map; without this
// the "+" add-item on the minicard did nothing. The blank-input guard and the
// title parsing live in the pure, unit-tested models/lib/checklistItemTitles.js.
function addMinicardChecklistItems(textarea, checklist) {
if (!textarea || !checklist) return;
const titles = parseChecklistItemTitles(textarea.value);
if (titles.length === 0) return;
const items = checklist.items();
const lastItem = items[items.length - 1] || null;
let sortIndex = Utils.calculateIndexData(lastItem, null).base;
titles.forEach(title => {
ChecklistItems.insert(buildChecklistItemPayload(title, checklist, sortIndex));
sortIndex += 1;
});
textarea.value = '';
textarea.focus();
}
Template.minicardChecklist.events({
'click .js-convert-checklist-item-to-card'(event) {
event.stopPropagation();
@@ -417,25 +448,26 @@ Template.minicardChecklist.events({
}
$form.find('.js-close-inlined-form').trigger('click');
},
// #6440: explicit click handler for the minicard "+" add-item Save button.
// The native form `submit` event does not fire on the minicard (the form is
// inside the `a.minicard-wrapper` anchor), so — unlike card-detail — we cannot
// rely on `submit .js-add-checklist-item`. This mirrors the already-working
// `click .js-submit-edit-checklist-item-form` above.
'click .js-submit-add-checklist-item-form'(event) {
event.preventDefault();
event.stopPropagation();
const $form = $(event.currentTarget).closest('form');
const textarea = $form.find('textarea.js-add-checklist-item')[0];
const formData = Blaze.getData($form[0]) || Template.currentData() || {};
addMinicardChecklistItems(textarea, formData.checklist);
},
// Kept for completeness (e.g. card-detail-style contexts); on the minicard this
// rarely fires, so the click handler above is the real path. See #6440.
'submit .js-add-checklist-item'(event, tpl) {
event.preventDefault();
const textarea = tpl.find('textarea.js-add-checklist-item');
if (!textarea) return;
const title = textarea.value.trim();
const data = Template.currentData() || {};
const checklist = data.checklist;
if (!checklist || !title) return;
const items = checklist.items();
const lastItem = items[items.length - 1] || null;
const sortIndex = Utils.calculateIndexData(lastItem, null).base;
ChecklistItems.insert({
title,
checklistId: checklist._id,
cardId: checklist.cardId,
sort: sortIndex,
});
textarea.value = '';
textarea.focus();
addMinicardChecklistItems(textarea, data.checklist);
},
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
Popup.back();
+58 -13
View File
@@ -17,24 +17,69 @@ export function canNavigateToSubtaskBoard(board) {
// the parent card again, and so the behaviour is unit-testable.
//
// Returns the FlowRouter 'card' route params taken from the subtask, or
// undefined when the subtask's board is not loaded yet (see #4762 guard).
// undefined for a malformed subtask (no usable card id / board id).
//
// #1853: "Subtask board id missing: cannot read property _id of undefined".
// The subtask often lives on ANOTHER board (the deposit/target board) whose
// document is not in minimongo while the parent card's board is open. In that
// case subtask.board() yields nothing; instead of crashing (the original
// TypeError) or silently doing nothing (the post-#4762 behaviour), fall back
// to the subtask's own boardId and the default 'board' slug — the 'card'
// route action only uses :boardId/:cardId (config/router.js) and its
// subscriptions load the board, so navigation succeeds without a reload.
// 'board' is the model's own default slug (models/boards.js).
export function subtaskNavTarget(subtask) {
if (!subtask) {
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (!canNavigateToSubtaskBoard(board)) {
return undefined;
}
// Prefer the real card id (resolves linked subtask cards to their source);
// fall back to the plain _id when getRealId is unavailable (e.g. in tests).
const cardId =
typeof subtask.getRealId === 'function' ? subtask.getRealId() : subtask._id;
return {
boardId: board._id,
slug: board.slug,
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
(typeof subtask.getRealId === 'function' ? subtask.getRealId() : undefined) ||
subtask._id;
if (!cardId) {
// Malformed subtask document: nothing sane to navigate to.
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (canNavigateToSubtaskBoard(board)) {
return {
boardId: board._id,
slug: board.slug,
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
}
if (subtask.boardId) {
// Board doc missing locally (#1853 / #4762): navigate by id and let the
// route load the board. The slug segment is cosmetic for the card route.
return {
boardId: subtask.boardId,
slug: 'board',
cardId,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
};
}
// No board reference at all: broken subtask, don't navigate (and don't
// throw — the caller shows a warning instead).
return undefined;
}
// Same idea for the "Go to board" popup entry (js-go-to-subtask-board):
// prefer the loaded board's id/slug, fall back to the subtask's boardId so a
// not-yet-subscribed deposit board is still reachable (#1853).
export function subtaskBoardNavTarget(subtask) {
if (!subtask) {
return undefined;
}
const board = typeof subtask.board === 'function' ? subtask.board() : undefined;
if (canNavigateToSubtaskBoard(board)) {
return { id: board._id, slug: board.slug };
}
if (subtask.boardId) {
return { id: subtask.boardId, slug: 'board' };
}
return undefined;
}
+23 -6
View File
@@ -3,7 +3,10 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Cards from '/models/cards';
import { Filter } from '/client/lib/filter';
import { subtaskStatusLabel } from './subtaskStatusHelpers';
import { subtaskNavTarget } from './subtaskViewHelpers';
import {
subtaskNavTarget,
subtaskBoardNavTarget,
} from './subtaskViewHelpers';
import { Utils } from '/client/lib/utils';
Template.subtasks.events({
@@ -125,20 +128,34 @@ Template.subtaskActionsPopup.events({
const subtask = Template.currentData().subtask;
// #3743: open the SUBTASK card itself, not the parent/current card.
// subtaskNavTarget derives boardId/slug/cardId from the subtask only.
// #4762: it also returns undefined while the subtask's board is not
// loaded yet (ReactiveCache miss), mirroring js-go-to-subtask-board.
// #1853 / #4762: when the subtask's board (often a DIFFERENT deposit
// board) is not in minimongo yet, it falls back to the subtask's own
// boardId so the route loads the board — no TypeError, no dead button.
// It only returns undefined for a truly broken subtask document.
const target = subtaskNavTarget(subtask);
if (target) {
FlowRouter.go('card', target);
} else {
// Broken subtask (no card id / board id): warn instead of throwing.
console.warn(
'Cannot view subtask: missing board/card id on subtask',
subtask && subtask._id,
);
}
}
},
'click .js-go-to-subtask-board'() {
const subtask = Template.currentData().subtask;
const board = subtask.board();
if (board) {
// #1853: fall back to subtask.boardId when the board doc is not loaded.
const target = subtaskBoardNavTarget(subtask);
if (target) {
Popup.close();
FlowRouter.go('board', { id: board._id, slug: board.slug });
FlowRouter.go('board', target);
} else {
console.warn(
'Cannot go to subtask board: missing board id on subtask',
subtask && subtask._id,
);
}
},
'click .js-delete-subtask' : Popup.afterConfirm('subtaskDelete', async function () {
+28 -9
View File
@@ -130,8 +130,8 @@ textarea.editor {
}
input[type="submit"],
button {
background: #000;
background: linear-gradient(#000, #000);
background: var(--theme-accent, #000);
background: linear-gradient(var(--theme-accent, #000), var(--theme-accent, #000));
border: none;
cursor: pointer;
display: inline-block;
@@ -150,14 +150,14 @@ input[type="submit"]:hover,
button:hover,
input[type="submit"]:focus,
button:focus {
background: #222;
background: linear-gradient(#222, #222);
background: var(--theme-accent, #222);
background: linear-gradient(var(--theme-accent, #222), var(--theme-accent, #222));
color: #fff;
}
input[type="submit"]:active,
button:active {
background: #111;
background: linear-gradient(#111, #111);
background: var(--theme-accent, #111);
background: linear-gradient(var(--theme-accent, #111), var(--theme-accent, #111));
box-shadow: inset 0 3px 6px rgba(0,0,0,0.3);
color: #fff;
}
@@ -172,7 +172,7 @@ button:active:active {
}
input[type="submit"].primary,
button.primary {
background: #005377;
background: var(--theme-accent, #005377);
box-shadow: 0 1px 0 #4d4d4d;
color: #fff;
}
@@ -180,11 +180,11 @@ input[type="submit"].primary:hover,
button.primary:hover,
input[type="submit"].primary:focus,
button.primary:focus {
background: #004766;
background: var(--theme-accent, #004766);
}
input[type="submit"].primary:active,
button.primary:active {
background: #01628c;
background: var(--theme-accent, #01628c);
}
input[type="submit"].negate,
button.negate {
@@ -298,6 +298,25 @@ textarea::-moz-placeholder {
font-size: clamp(20px, 4vw, 26px);
margin: 0.5vh 1.5vw;
}
/* CJK (Japanese/Chinese/Korean) labels contain no spaces, so browsers may
insert a line break between any two characters. In the narrow add-card /
edit footers this made short labels such as "追加" (Add) and "リンク"
(Link) wrap mid-word. `word-break: keep-all` forbids breaks inside CJK
words while leaving Latin wrapping behavior unchanged, and `flex-wrap`
lets the footer wrap between its links instead of inside them.
See https://github.com/wekan/wekan/issues/4023 */
.edit-controls,
.add-controls {
flex-wrap: wrap;
word-break: keep-all;
}
.edit-controls button[type=submit],
.add-controls button[type=submit] {
white-space: nowrap;
}
.add-controls .quiet {
white-space: nowrap;
}
[type="checkbox"]:not(:checked),
[type="checkbox"]:checked {
position: absolute;
+20 -1
View File
@@ -736,9 +736,28 @@ form.inlined-form .list-composer {
flex: 1 1 auto;
flex-direction: column;
display: flex;
overflow-y: auto;
/* #5439: always show a scrollbar on the list body across desktop and mobile
browsers (overlay scrollbars on macOS/iOS/Android/Firefox otherwise auto-
hide it). Keep these declarations in sync with models/lib/scrollbarCss.js
(alwaysVisibleScrollbarCss), which is unit tested. */
overflow-y: scroll;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.35) rgba(0, 0, 0, 0.08);
scrollbar-gutter: stable;
padding: 5px 11px;
}
.list-body::-webkit-scrollbar {
width: 10px;
height: 10px;
-webkit-appearance: none;
}
.list-body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.08);
}
.list-body::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 5px;
}
.list-body .minicards {
flex-grow: 1;
flex-shrink: 0;
+141 -49
View File
@@ -3,6 +3,11 @@ import { TAPi18n } from '/imports/i18n';
import { EscapeActions } from '/client/lib/escapeActions';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import Cards from '/models/cards';
import {
isDegenerateSortGap,
computeRepairedDropIndex,
} from '/models/lib/cardSortRepair';
require('/client/lib/jquery-ui.js')
const { calculateIndex } = Utils;
@@ -19,9 +24,17 @@ const { calculateIndex } = Utils;
//
// The old per-list "min width / max width / automatic" knobs are gone — the
// rendered width is now a single hard value (see list.jade).
//
// #5659: the default/minimum width and the resolution order live in ONE
// Meteor-free module shared with models/users.js, so every path (member,
// anonymous public-board visitor, fixed mode) falls back to the SAME default
// and all lists render the same width when nothing is customized.
// ---------------------------------------------------------------------------
const DEFAULT_LIST_WIDTH = 272;
const MIN_LIST_WIDTH = 270;
import {
DEFAULT_LIST_WIDTH,
MIN_LIST_WIDTH,
resolveListWidth,
} from '/models/lib/listWidth';
function isPersonalListWidth(boardId) {
const board = ReactiveCache.getBoard(boardId);
@@ -122,25 +135,28 @@ function saveAnonFixedListWidth(boardId, width) {
function effectiveListWidth(list) {
if (!list) return DEFAULT_LIST_WIDTH;
// #5729 In fixed width mode every list renders at the single shared value.
if (isFixedListWidth(list.boardId)) {
return fixedListWidthValue(list.boardId);
// #5659: all inputs are gathered here, but the fallback order and the ONE
// default width live in models/lib/listWidth.js (shared + unit tested).
const fixedEnabled = isFixedListWidth(list.boardId);
const personalMode = isPersonalListWidth(list.boardId);
let personalWidth = null;
if (personalMode) {
const user = ReactiveCache.getCurrentUser();
if (user) {
const widths = user.getListWidths();
personalWidth = widths[list.boardId] && widths[list.boardId][list._id];
} else {
personalWidth = readAnonListWidth(list.boardId, list._id);
}
}
const shared =
typeof list.width === 'number' && list.width >= MIN_LIST_WIDTH
? list.width
: DEFAULT_LIST_WIDTH;
if (!isPersonalListWidth(list.boardId)) {
return shared;
}
const user = ReactiveCache.getCurrentUser();
if (user) {
const widths = user.getListWidths();
const w = widths[list.boardId] && widths[list.boardId][list._id];
return typeof w === 'number' && w >= MIN_LIST_WIDTH ? w : shared;
}
const w = readAnonListWidth(list.boardId, list._id);
return w || shared;
return resolveListWidth({
// #5729 In fixed width mode every list renders at the single shared value.
fixedEnabled,
fixedWidth: fixedEnabled ? fixedListWidthValue(list.boardId) : null,
sharedWidth: list.width,
personalMode,
personalWidth,
});
}
// Can the current user change THIS list's width by dragging?
@@ -276,7 +292,35 @@ Template.list.onRendered(function () {
const prevCardDom = ui.item.prev('.js-minicard').get(0);
const nextCardDom = ui.item.next('.js-minicard').get(0);
const nCards = MultiSelection.isActive() ? MultiSelection.count() : 1;
const sortIndex = calculateIndex(prevCardDom, nextCardDom, nCards);
let sortIndex = calculateIndex(prevCardDom, nextCardDom, nCards);
// #3826: cards created by the "add subtask" flow were all inserted with
// a constant sort of -1, so a list full of subtask cards contains only
// ties. No number lies strictly between two equal sorts, so
// calculateIndex returns base === neighbour sort with increment 0: the
// write is then discarded by Card.move()'s no-op guard and the card
// snaps back (and a multi-selection drop would give every selected card
// the SAME sort, losing their relative order). When the drop landed in
// such a degenerate gap, capture the target list's sibling cards in
// their current visual order (before sortable('cancel') reshuffles the
// DOM) so their sorts can be repaired below.
const prevCardData = prevCardDom ? Blaze.getData(prevCardDom) : null;
const nextCardData = nextCardDom ? Blaze.getData(nextCardDom) : null;
let orderedSiblingCards = null;
if (
isDegenerateSortGap(
prevCardData ? prevCardData.sort : null,
nextCardData ? nextCardData.sort : null,
)
) {
orderedSiblingCards = ui.item
.parent()
.children(itemsSelector)
.not(ui.item)
.toArray()
.map(el => Blaze.getData(el))
.filter(Boolean);
}
const listData = Blaze.getData(ui.item.parents('.list').get(0));
const listId = listData._id;
const currentBoard = Utils.getCurrentBoard();
@@ -303,6 +347,25 @@ Template.list.onRendered(function () {
// Blaze with the below query.
$cards.sortable('cancel');
// #3826: the drop landed between two cards whose sorts do not strictly
// increase (duplicate -1 sorts from subtask creation, or legacy data).
// Repair the sibling sorts with the minimal bumps that make the visible
// order strictly increasing, then recompute the drop index from the
// repaired neighbours: base now falls strictly between them and the
// increment is strictly positive (distinct sorts for a multi-selection).
if (orderedSiblingCards && prevCardData && nextCardData) {
const repairedDrop = computeRepairedDropIndex(
orderedSiblingCards,
prevCardData,
nextCardData,
nCards,
);
repairedDrop.updates.forEach(u => {
Cards.update(u._id, { $set: { sort: u.sort } });
});
sortIndex = { base: repairedDrop.base, increment: repairedDrop.increment };
}
if (MultiSelection.isActive()) {
ReactiveCache.getCards(MultiSelection.getMongoSelector(), { sort: ['sort'] }).forEach((card, i) => {
const newSwimlaneId = targetSwimlaneId
@@ -326,37 +389,59 @@ Template.list.onRendered(function () {
if (boardComponent) boardComponent.setIsDragging(false);
},
sort(event, ui) {
const $boardCanvas = $('.board-canvas');
const boardCanvas = $boardCanvas[0];
if (event.pageX < 10) { // scroll to the left
boardCanvas.scrollLeft -= 15;
ui.helper[0].offsetLeft -= 15;
// #443: the HORIZONTAL scroller is the .js-lists lane (.swimlane has
// overflow: auto), NOT .board-canvas, which only overflows vertically —
// the old code compared against a scrollLeftMax of 0 and never fired, so
// dragging a card toward an off-screen list never auto-scrolled the board.
const { computeEdgeScroll, findLaneUnderPointer } = require('/imports/lib/boardAutoScroll');
let scrolled = false;
const lanes = document.querySelectorAll('.js-lists');
const rects = Array.prototype.map.call(lanes, el => el.getBoundingClientRect());
const laneIndex = findLaneUnderPointer(rects, event.clientX, event.clientY);
if (laneIndex !== -1) {
const nextLeft = computeEdgeScroll({
pointer: event.clientX,
lowEdge: rects[laneIndex].left,
highEdge: rects[laneIndex].right,
scrollPos: lanes[laneIndex].scrollLeft,
scrollSize: lanes[laneIndex].scrollWidth,
clientSize: lanes[laneIndex].clientWidth,
});
if (nextLeft !== null) {
lanes[laneIndex].scrollLeft = nextLeft;
scrolled = true;
}
}
if (
event.pageX > boardCanvas.offsetWidth - 10 &&
boardCanvas.scrollLeft < $boardCanvas.data('scrollLeftMax') // don't scroll more than possible
) { // scroll to the right
boardCanvas.scrollLeft += 15;
// vertical auto-scroll stays on the board canvas
const canvas = document.querySelector('.board-canvas');
if (canvas) {
const crect = canvas.getBoundingClientRect();
const nextTop = computeEdgeScroll({
pointer: event.clientY,
lowEdge: crect.top,
highEdge: crect.bottom,
scrollPos: canvas.scrollTop,
scrollSize: canvas.scrollHeight,
clientSize: canvas.clientHeight,
});
if (nextTop !== null) {
canvas.scrollTop = nextTop;
scrolled = true;
}
}
if (
event.pageY > boardCanvas.offsetHeight - 10 &&
event.pageY + boardCanvas.scrollTop < $boardCanvas.data('scrollTopMax') // don't scroll more than possible
) { // scroll to the bottom
boardCanvas.scrollTop += 15;
// #6477: after an auto-scroll, jQuery UI sortable's cached container/item
// geometry is STALE — it snapshots positions at drag start and on its own
// placeholder moves, never on our manual scroll here. With long/overflowing
// lists (exactly what triggers auto-scroll) the drop then resolved against a
// stale map and parked the placeholder in the WRONG swimlane's copy of the
// list, so a card dragged between swimlanes snapped back into its source
// swimlane (the stop handler's ui.item.parents('.swimlane') faithfully
// persisted it). Re-cache positions so the placeholder and drop resolve
// against the scrolled layout. Complements the DOM-mutation refresh in
// listBody.js (#2769), which does not fire on scroll.
if (scrolled) {
$cards.sortable('refreshPositions');
}
if (event.pageY < 10) { // scroll to the top
boardCanvas.scrollTop -= 15;
}
},
activate(event, ui) {
const $boardCanvas = $('.board-canvas');
const boardCanvas = $boardCanvas[0];
// scrollTopMax and scrollLeftMax only available at Firefox (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTopMax)
// https://www.it-swarm.com.de/de/javascript/so-erhalten-sie-den-maximalen-dokument-scrolltop-wert/1069126844/
$boardCanvas.data('scrollTopMax', boardCanvas.scrollHeight - boardCanvas.clientTop);
// https://stackoverflow.com/questions/5138373/how-do-i-get-the-max-value-of-scrollleft/5704386#5704386
$boardCanvas.data('scrollLeftMax', boardCanvas.scrollWidth - boardCanvas.clientWidth);
},
});
@@ -384,6 +469,13 @@ Template.list.onRendered(function () {
const currentBoardId = Tracker.nonreactive(() => {
return Session.get('currentBoard');
});
// #1554: reactive dependency so newly added / re-rendered minicards are
// (re)initialized as droppable for label/member drags. The dependency
// (Cards.find(...).fetch()) was dropped in 7673c77c5, so the autorun ran
// ONCE per list render and cards added afterwards silently rejected
// sidebar label/member drops until the board was re-entered. Re-running
// .droppable() on initialized elements is an idempotent option refresh.
ReactiveCache.getCards({ boardId: currentBoardId });
Tracker.afterFlush(() => {
$cards.find(itemsSelector).droppable({
hoverClass: 'draggable-hover-card',
+1 -1
View File
@@ -46,7 +46,7 @@ template(name="addCardForm")
if getLabels
.minicard-labels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title="{{name}}")
textarea.minicard-composer-textarea.js-card-title(autofocus dir="auto")
if members.get
.minicard-members.js-minicard-composer-members
+136 -15
View File
@@ -9,6 +9,14 @@ import Swimlanes from '/models/swimlanes';
import { Filter } from '/client/lib/filter';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
import { sortCardsByTitle } from '/models/lib/sortCardsByTitle';
import { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import {
mutationsChangeDragGeometry,
findActiveCardDrag,
} from '/client/lib/cardDragGeometry';
import autosize from 'autosize';
// SubsManager removed for Meteor 3 migration
@@ -338,20 +346,36 @@ Template.listBody.helpers({
if (!sortBy) {
sortBy = defaultSort;
}
const selector = {
listId: Template.currentData()._id,
archived: false,
};
if (swimlaneId) {
// Fallback: also show cards with no swimlaneId (null/empty) so that
// pre-migration / orphaned cards are always visible without a DB migration.
selector.$or = [
{ swimlaneId },
{ swimlaneId: null }, // null covers null AND missing field
{ swimlaneId: '' }, // empty string from shared-lists era
];
// #6441: build the swimlane-membership fallback as a single `swimlaneId:
// { $in: [...] }` clause (via the shared, unit-tested helper) instead of a
// bare top-level `$or`, so it never competes with the board Filter's own
// top-level `$or` (labels/members/exceptions) and the label filter applies
// board-wide across every swimlane.
// #6443: in the first swimlane also surface orphaned cards (a swimlaneId
// pointing at a deleted swimlane) so they are not invisible in swimlane view.
const list = Template.currentData();
const selector = listCardsSelector(
list._id,
swimlaneId,
list.orphanedCardsSwimlaneIds
? list.orphanedCardsSwimlaneIds(swimlaneId)
: undefined,
);
const mongoSelector = Filter.mongoSelector(selector);
// Lazy card loading: the board publication ships no cards, so subscribe to
// just this list/swimlane window (grows with `limit` as the user scrolls)
// plus its reactive total count. Default mode keeps everything in minimongo
// already, so these subscriptions are skipped.
if (list.boardId && isLazyCards(list.boardId)) {
tpl.subscribe('boardCardsWindow', list.boardId, mongoSelector, sortBy, limit);
tpl.subscribe(
'boardListCardCount',
windowCountId(list._id, swimlaneId),
list.boardId,
mongoSelector,
);
}
const ret = ReactiveCache.getCards(Filter.mongoSelector(selector), {
const ret = ReactiveCache.getCards(mongoSelector, {
// sort: ['sort'],
sort: sortBy,
limit,
@@ -362,6 +386,13 @@ Template.listBody.helpers({
showSpinner(swimlaneId) {
const tpl = Template.instance();
const list = Template.currentData();
if (isLazyCards()) {
// In lazy mode minimongo only holds the loaded window, so the total comes
// from the server count published into BoardListCardCounts.
const countDoc = BoardListCardCounts.findOne(windowCountId(list._id, swimlaneId));
const total = countDoc ? countDoc.count : 0;
return total > tpl.cardlimit.get();
}
return list.cards(swimlaneId).length > tpl.cardlimit.get();
},
@@ -426,6 +457,80 @@ Template.listBody.events({
},
});
// #2769 Card lands in the WRONG swimlane when it is dropped while a connected
// list's layout changed mid-drag. jQuery UI sortable snapshots the geometry
// of every connected `.js-minicards` container once at drag start
// (_mouseStart runs refreshPositions() BEFORE the `start` callback) and only
// re-snapshots after its own placeholder moves — never when Blaze mutates the
// DOM. Two such mutations routinely happen during a drag: another user's
// newly added card is reactively inserted into the target list (the issue's
// "target column where a new card is being added"), and the dragging user's
// own open add-card composer is closed by the `start` callback's
// EscapeActions call, AFTER the snapshot. Every list/swimlane below the
// change then shifts vertically while the cached rectangles stay put, so the
// drop resolves against a stale map: the placeholder is parked in the
// neighbouring swimlane's copy of the list (hence the reporter's "no drop
// shadow in the target column") and the stop handler's
// ui.item.parents('.swimlane') (client/components/lists/list.js) faithfully
// persists that wrong swimlane.
// Fix: watch each list's minicards for REAL DOM changes (Blaze insertions and
// removals — not jQuery UI's own placeholder/helper churn, which the library
// already follows with its own refresh) and re-cache the active drag's
// geometry with sortable('refresh'). The decision helpers are pure and unit
// tested in tests/cardDragGeometry.test.cjs.
Template.listBody.onRendered(function () {
const minicardsEl = this.find('.js-minicards');
if (!minicardsEl || typeof MutationObserver === 'undefined') {
return;
}
this.dragGeometryRefreshQueued = false;
this.dragGeometryObserver = new MutationObserver(mutations => {
// Cheapest gate first: no drag helper on the page means no drag at all
// (this observer also fires on every ordinary reactive card update).
if (!document.getElementsByClassName('ui-sortable-helper').length) {
return;
}
if (!mutationsChangeDragGeometry(mutations)) {
return;
}
if (this.dragGeometryRefreshQueued) {
return;
}
this.dragGeometryRefreshQueued = true;
// Coalesce bursts (Blaze can insert several nodes per flush); by the time
// the timeout runs, this tick's layout is final and measurable.
setTimeout(() => {
this.dragGeometryRefreshQueued = false;
const active = findActiveCardDrag(
$('.js-minicards')
.toArray()
.map(el => $.data(el, 'ui-sortable')),
);
if (active) {
try {
// Re-collects the items of every connected container and re-measures
// all container rectangles, so the pointer is matched against the
// CURRENT layout for the rest of the drag.
active.refresh();
} catch (e) {
// A failed re-measure must never break the drag itself.
}
}
}, 0);
});
this.dragGeometryObserver.observe(minicardsEl, {
childList: true,
subtree: true,
});
});
Template.listBody.onDestroyed(function () {
if (this.dragGeometryObserver) {
this.dragGeometryObserver.disconnect();
this.dragGeometryObserver = null;
}
});
function toggleValueInReactiveArray(reactiveValue, value) {
const array = reactiveValue.get();
const valueIndex = array.indexOf(value);
@@ -706,14 +811,19 @@ Template.linkCardPopup.helpers({
archived: false,
linkedId: { $nin: ownCardsIds },
_id: { $nin: ownCardsIds },
type: { $nin: ['template-card'] },
// #5808: never offer an existing linked card/board as a link target —
// linking to one builds a chain of linkedId pointers that renders the
// card inaccessible. Only real cards may be linked.
type: { $nin: ['template-card', 'cardType-linkedCard', 'cardType-linkedBoard'] },
};
if (tpl.selectedBoardId.get()) selector.boardId = tpl.selectedBoardId.get();
if (tpl.selectedSwimlaneId.get()) selector.swimlaneId = tpl.selectedSwimlaneId.get();
if (tpl.selectedListId.get()) selector.listId = tpl.selectedListId.get();
const ret = ReactiveCache.getCards(selector, { sort: { sort: 1 } });
return ret;
// #5394: sort the Cards dropdown alphabetically by title
// (case-insensitive) so a card can be found on boards with many cards.
return sortCardsByTitle(ret);
},
isTitleDefault(title) {
@@ -790,6 +900,17 @@ Template.linkCardPopup.events({
Popup.back();
return;
}
// #5808: refuse to link to a linked card/board or to a card that links back
// to this board — those build a chain/cycle of linkedId pointers that renders
// the cards inaccessible. The <select> already filters these out, but its
// options can be stale, so re-check the resolved target here.
const targetCard = ReactiveCache.getCard(linkedId);
const ownCardsIds = tpl.board.cards().map(card => card.getRealId());
if (!isLinkableCardTarget(targetCard, ownCardsIds)) {
alert(TAPi18n.__('error-linked-card-not-allowed'));
Popup.back();
return;
}
const nextCardNumber = await tpl.board.getNextCardNumber();
const sortIndex = tpl.getSortIndex();
const _id = Cards.insert({
+21 -9
View File
@@ -1,7 +1,8 @@
template(name="listHeader")
.list-header.js-list-header.nodragscroll(
class="{{#if limitToShowCardsCount}}list-header-card-count{{/if}}"
class=colorClass)
class=colorClass
style=colorStyle)
//- #459: keyboard/screen-reader list reordering (visually hidden, focusable).
.list-move-buttons.sr-only
a.js-list-move-left(href="#" role="button" aria-label="{{_ 'move-list-left'}}") {{_ 'move-list-left'}}
@@ -15,7 +16,7 @@ template(name="listHeader")
i.fa.fa-caret-left
else
if collapsed
if showCardsCountForList cards.length
if showCardsCountForList cardsCountWhole
br
span.cardCount {{cardsCount}}
if isMiniScreen
@@ -26,10 +27,10 @@ template(name="listHeader")
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cardsCountWhole}}
|/#{wipLimit.value})
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if showCardsCountForList cardsCountWhole
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cardsCountWhole}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
@@ -47,11 +48,11 @@ template(name="listHeader")
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cardsCountWhole}}
|/#{wipLimit.value})
unless collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if showCardsCountForList cardsCountWhole
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cardsCountWhole}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
@@ -100,6 +101,10 @@ template(name="listHeader")
i.fa.fa-plus
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
//- #6465: far-right add-list button. Opens the inline Add List
//- composer AFTER this list (right in LTR / left in RTL).
a.js-add-list-here.list-header-plus-top(title="{{_ 'add-list'}}")
i.fa.fa-plus-square-o
template(name="editListTitleForm")
.list-composer
@@ -207,7 +212,10 @@ template(name="listMorePopup")
span {{_ 'link-list'}}
= ' '
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
input.inline-input(type="text" readonly value="{{ rootUrl }}")
//- #6459: was {{ rootUrl }}, a helper that does not exist anywhere, so the
//- link input was always empty. absoluteUrl resolves a URL via a card on
//- this list.
input.inline-input(type="text" readonly value="{{ absoluteUrl }}")
| {{_ 'added'}}
span.date(title=list.createdAt) {{ displayDate createdAt 'LLL' }}
//unless currentUser.isWorker
@@ -279,6 +287,10 @@ template(name="setListColorPopup")
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
// #5514: native color wheel for picking a custom RGB / hex color
.color-wheel-wrap
label(for="listColorWheel") {{_ 'custom-color'}}
input.js-list-color-wheel#listColorWheel(type="color" value=currentColorHex title="{{_ 'custom-color'}}")
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
+69 -27
View File
@@ -5,8 +5,17 @@ import dragscroll from '@wekanteam/dragscroll';
import { BoardSwimlaneListDialog } from '/client/lib/dialogWithBoardSwimlaneList';
import Cards from '/models/cards';
import { LIST_COLORS } from '/models/metadata/colors';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { lazyListCardCount } from '/client/lib/lazyCards';
// #5659: single source of truth for the default/minimum list width, shared
// with client/components/lists/list.js and models/users.js.
import {
DEFAULT_LIST_WIDTH,
MIN_LIST_WIDTH,
normalizeListWidth,
} from '/models/lib/listWidth';
let listsColors;
Meteor.startup(() => {
@@ -59,24 +68,33 @@ Template.listHeader.helpers({
swimlaneId = list.swimlaneId || '';
}
const ret = list.cards(swimlaneId).length;
return ret;
// In lazy mode minimongo holds only the loaded window, so use the accurate
// server count for the same selector; fall back to the loaded length.
const lazyCount = lazyListCardCount(list, swimlaneId);
return lazyCount !== null ? lazyCount : list.cards(swimlaneId).length;
},
reachedWipLimit() {
const list = Template.currentData();
return (
list.getWipLimit('enabled') &&
list.getWipLimit('value') <= list.cards().length
);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') <= count;
},
exceededWipLimit() {
const list = Template.currentData();
return (
list.getWipLimit('enabled') &&
list.getWipLimit('value') < list.cards().length
);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') < count;
},
// Accurate whole-list card count (used by the badge visibility, WIP-limit
// number and pluralization in the template). In lazy mode `cards.length`
// (= list.cards().length) is only the loaded window, so prefer the server count.
cardsCountWhole() {
const list = Template.currentData();
const lazyCount = lazyListCardCount(list, undefined);
return lazyCount !== null ? lazyCount : list.cards().length;
},
showCardsCountForList(count) {
@@ -141,10 +159,9 @@ Template.listHeader.helpers({
Template.listHeader.onCreated(function () {
this.reachedWipLimit = function () {
const list = Template.currentData();
return (
list.getWipLimit('enabled') &&
list.getWipLimit('value') <= list.cards().length
);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') <= count;
};
});
@@ -191,6 +208,20 @@ Template.listHeader.events({
Utils.setListCollapseState(list, !status);
},
'click .js-open-list-menu': Popup.open('listAction'),
// #6465: open the inline Add List composer after this list. Record the
// swimlane it is displayed in (board-wide lists are not bound to one) so the
// new list is created in the right swimlane; the composer renders after this
// list in the DOM (right in LTR / left in RTL).
'click .js-add-list-here'(event) {
event.preventDefault();
const list = Template.currentData();
const swimlaneDom = $(event.currentTarget).closest('.js-swimlane');
const swimlaneId = swimlaneDom.length
? swimlaneDom.attr('id').replace('swimlane-', '')
: null;
Session.set('wekan-add-list-swimlane', swimlaneId);
Session.set('wekan-add-list-after', list._id);
},
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(event.target).parents(
`#js-list-${Template.currentData()._id}`,
@@ -305,11 +336,10 @@ Template.setWipLimitPopup.events({
async 'click .js-enable-wip-limit'() {
const list = Template.currentData();
// Prevent user from using previously stored wipLimit.value if it is less than the current number of cards in the list
if (
!list.getWipLimit('enabled') &&
list.getWipLimit('value') < list.cards().length
) {
await list.setWipLimit(list.cards().length);
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
if (!list.getWipLimit('enabled') && list.getWipLimit('value') < count) {
await list.setWipLimit(count);
}
Meteor.call('enableWipLimit', list._id);
},
@@ -357,8 +387,10 @@ Template.listMorePopup.events({
],
}).length === 0
) {
allCardIds.map(_id => Cards.remove(_id));
Lists.remove(list._id);
// Soft delete (docs/Features/Undo/Undo.md): mark the list + its cards deleted
// (server cascades) instead of destroying them, so the delete is restorable
// and undoable (#1023). Replaces the old hard Cards.remove / Lists.remove.
Meteor.call('lists.softRemove', list._id);
} else {
const message = `${TAPi18n.__(
'delete-linked-cards-before-this-list',
@@ -476,6 +508,10 @@ Template.setListColorPopup.helpers({
return tpl.currentColor.get() === color;
}
},
// #5514: current color as a '#rrggbb' hex for the native color wheel <input>.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#0079bf';
},
});
Template.setListColorPopup.events({
@@ -483,6 +519,13 @@ Template.setListColorPopup.events({
const paletteData = Blaze.getData(event.currentTarget);
tpl.currentColor.set(paletteData?.color);
},
// #5514: picking from the native color wheel stores a custom hex.
'input .js-list-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
async 'submit form'(event, tpl) {
event.preventDefault();
try {
@@ -542,12 +585,12 @@ function readAnonFixedListWidth(boardId) {
if (stored) {
const widths = JSON.parse(stored);
const w = widths[boardId];
if (typeof w === 'number' && w >= 270) return w;
if (typeof w === 'number' && w >= MIN_LIST_WIDTH) return w;
}
} catch (e) {
console.warn('Error reading fixed list width from localStorage:', e);
}
return 272;
return DEFAULT_LIST_WIDTH;
}
function isFixedListWidth(boardId) {
@@ -591,8 +634,7 @@ Template.setListWidthPopup.helpers({
if (isFixedListWidth(list.boardId)) {
return fixedListWidthValue(list.boardId);
}
const shared =
typeof list.width === 'number' && list.width >= 270 ? list.width : 272;
const shared = normalizeListWidth(list.width);
if (!isPersonalListWidth(list.boardId)) {
return shared;
}
@@ -600,7 +642,7 @@ Template.setListWidthPopup.helpers({
if (user) {
const widths = user.getListWidths();
const w = widths[list.boardId] && widths[list.boardId][list._id];
return typeof w === 'number' && w >= 270 ? w : shared;
return normalizeListWidth(w, shared);
}
return shared;
},
@@ -682,7 +724,7 @@ Template.setListWidthPopup.events({
const boardId = list.boardId;
const width = parseInt(tpl.$('.list-width-value').val(), 10);
if (!width || width < 270) {
if (!width || width < MIN_LIST_WIDTH) {
tpl.$('.list-width-error').click();
return;
}
+49
View File
@@ -0,0 +1,49 @@
/* Custom theme colors (docs/Theme/Theme.md) — the flat/clear categories let the
* user pick their own colors, applied as CSS variables on :root by
* globalThemeColor.js and consumed here. This is the initial, highest-impact
* surface (the header bars, primary buttons, sidebar button); deeper theming can
* grow from here. dark/special themes never set these variables. */
/* Flat: a single accent recolors the header bars and primary controls. The class
* lives on <body>; #header / #header-quick-access are descendants. */
.has-custom-theme-color #header,
.has-custom-theme-color #header-quick-access {
background: var(--theme-accent) !important;
}
.has-custom-theme-color button[type=submit].primary,
.has-custom-theme-color input[type=submit].primary,
.has-custom-theme-color .sidebar .sidebar-content .sidebar-btn {
background-color: var(--theme-accent) !important;
}
/* Clear: a two-color slide (gradient) across the header bars. */
.has-custom-theme-slide #header,
.has-custom-theme-slide #header-quick-access {
background: linear-gradient(135deg, var(--theme-accent), var(--theme-accent-2)) !important;
}
/* Select Color picker: category groups with a visible name above each group of
* colour swatches (docs/Theme/Theme.md). */
.theme-color-picker .theme-category-group {
margin-bottom: 8px;
clear: both; /* start below the previous group's floated swatches */
}
.theme-color-picker .theme-category-label {
display: block;
width: 100%;
clear: both; /* drop below the previous group's floats... */
text-align: left; /* ...so Flat/Clear/Dark/Special all sit at the left */
font-weight: bold;
margin: 2px 0 4px;
text-transform: capitalize;
}
/* No empty band above the first (Flat) title. */
.theme-color-picker .theme-category-group:first-child .theme-category-label {
margin-top: 0;
}
/* Tighten the Default (no override) row above the groups (Member Settings). */
.theme-color-picker ul.pop-over-list {
margin: 0 0 4px;
padding: 0;
}
+35 -2
View File
@@ -383,10 +383,29 @@ Blaze.Template.registerHelper(
'mentions',
new Template('mentions', function() {
const view = this;
// Admin Panel / Features / Security: read the setting FIRST (a reactive dependency
// of this viewer) and push the "always show all code as plain text" flag into the
// wekan-markdown package BEFORE rendering the inner markdown below. Doing it here —
// not only in the separate startup autorun — removes a re-render race: this viewer
// already re-renders whenever the setting doc changes (it reads it for stripLinks),
// and the inner markdown helper then reads the up-to-date flag in the SAME
// synchronous render, so toggling "always show all code as plain text" takes effect
// immediately. Without this the viewer could re-render (setting changed) BEFORE the
// startup autorun updated the flag, read the stale value, and never re-render again
// since it does not itself depend on that ReactiveVar.
const setting = ReactiveCache.getCurrentSetting();
if (typeof Markdown !== 'undefined' && Markdown.alwaysShowCodeAsText) {
Markdown.alwaysShowCodeAsText.set(!!(setting && setting.alwaysShowCodeAsText));
}
let content = Blaze.toHTML(view.templateContentBlock);
// Admin Panel / Features: when "render links as plain text" is enabled, every
// link (markdown [label](url) and raw HTML <a href>) is stripped to plain,
// non-clickable text everywhere rich text is shown. Reactive: toggling the
// setting re-renders viewers.
const stripLinks = !!(setting && setting.renderLinksAsPlainText);
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard)
return HTML.Raw(sanitizeHTML(content));
return HTML.Raw(sanitizeHTML(content, { stripLinks }));
const knowedUsers = [...new Set([...currentBoard.members
.filter(member => member.isActive)
.map(member => {
@@ -440,7 +459,7 @@ Blaze.Template.registerHelper(
content = content.replace(fullMention, Blaze.toHTML(link));
}
return HTML.Raw(sanitizeHTML(content));
return HTML.Raw(sanitizeHTML(content, { stripLinks }));
}),
);
@@ -473,3 +492,17 @@ Template.viewer.events({
}
},
});
// Admin Panel / Features / Security: keep the wekan-markdown package's
// "show all code as plain text" flag in sync with the alwaysShowCodeAsText setting.
// The package cannot import app code, so we push the setting into the reactive flag
// it exposes on the exported Markdown object; the markdown helper reads it reactively
// and shows the raw source (escaped, non-clickable, non-running) when enabled.
Meteor.startup(() => {
Tracker.autorun(() => {
const setting = ReactiveCache.getCurrentSetting();
if (typeof Markdown !== 'undefined' && Markdown.alwaysShowCodeAsText) {
Markdown.alwaysShowCodeAsText.set(!!(setting && setting.alwaysShowCodeAsText));
}
});
});
+3 -11
View File
@@ -88,17 +88,9 @@
cursor: pointer;
transition: background-color 0.2s;
}
.board-color-belize.js-new-search {
background-color: #216694 !important;
color: #fff !important;
border: none !important;
}
.board-color-belize.js-new-search:hover {
background-color: #1a4d6a !important;
}
.board-color-belize.js-new-search:active {
background-color: #0d334d !important;
}
/* The new-search button uses the .primary button style, so it follows the theme
override colour (default blue #005377, or the board/global theme). The old
hardcoded belize (#216694) rules were removed. */
.global-search-operator {
font-family: Courier;
}
+2 -2
View File
@@ -71,8 +71,8 @@ template(name="globalSearch")
aria-label="{{_ 'globalSearch-title'}}"
autofocus dir="auto"
)
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
span(aria-hidden="true") 🧹
button.primary.js-new-search(type="submit" aria-label="{{_ 'trello-clear-job'}}")
| {{_ 'trello-clear-job'}}
if searching
+spinner
else if hasResults
@@ -0,0 +1,74 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
// #5778 + docs/Theme/Theme.md: apply the user's optional GLOBAL theme color to the
// whole UI (All Boards, Search, Admin Panel, My Cards, etc.) via a `board-color-<name>`
// class on <body>, and apply CUSTOM colors (flat = 1 accent, clear = 2 = a gradient)
// as CSS variables consumed by customTheme.css.
//
// While viewing a single board, that board's own color owns the page, so we do NOT
// put the global color class on <body> there; but we DO expose the ACTIVE context's
// custom colors (the board's when on a board, the user's global override otherwise)
// so custom-colored boards and the custom global theme both render.
Meteor.startup(() => {
let appliedClass = null;
function applyClass(colorClass) {
if (colorClass === appliedClass) return;
try {
if (appliedClass) document.body.classList.remove(appliedClass);
if (colorClass) document.body.classList.add(colorClass);
} catch (_) {
// document.body may not exist yet in exotic embeddings; ignore.
}
appliedClass = colorClass;
}
function applyCustom(custom) {
try {
const root = document.documentElement;
const c1 = custom && custom[0];
const c2 = custom && custom[1];
if (c1) {
root.style.setProperty('--theme-accent', c1);
document.body.classList.add('has-custom-theme-color');
} else {
root.style.removeProperty('--theme-accent');
document.body.classList.remove('has-custom-theme-color');
}
if (c2) {
root.style.setProperty('--theme-accent-2', c2);
document.body.classList.add('has-custom-theme-slide');
} else {
root.style.removeProperty('--theme-accent-2');
document.body.classList.remove('has-custom-theme-slide');
}
} catch (_) {
// ignore
}
}
Tracker.autorun(() => {
const user = Meteor.user();
const globalColor = user && user.profile && user.profile.globalThemeColor;
if (globalColor) {
// #5778: a global override wins EVERYWHERE — including board pages. The header
// and .board-wrapper also prefer it (see header.jade / boardBody.jade), so the
// whole UI takes the chosen theme; unset it to return to per-board colors.
applyClass(`board-color-${globalColor}`);
applyCustom((user && user.profile && user.profile.globalThemeCustomColors) || []);
return;
}
// No global override: on a board expose that board's own custom colors (its color
// class already lives on .board-wrapper/#header); off a board, apply nothing.
const boardId = Session.get('currentBoard');
const board = boardId ? ReactiveCache.getBoard(boardId) : null;
applyClass(null);
applyCustom((board && board.customThemeColors) || []);
});
});
+35 -12
View File
@@ -5,8 +5,16 @@
z-index: 17;
}
#header #header-main-bar {
height: 40px;
padding: 7px 10px 0;
/* Flex row (was float-based) so the right button group — which ends with the
sidebar hamburger — is always pinned to the right edge at every window width,
instead of drifting to the middle as floats wrap. */
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 40px;
/* No right padding: the right gap is owned by the hamburger's margin so it can
match the ~8px gap to the right of the top-bar user name. (top right bottom left) */
padding: 7px 0 0 10px;
}
#header #header-main-bar h1 {
font-size: 20px;
@@ -39,15 +47,17 @@
opacity: 0.9;
}
#header #header-main-bar .board-header-btns {
display: block;
/* Flex so the buttons inside each group lay out (their float is ignored inside a
flex container) and the groups themselves are flex items of the header bar. */
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 3px;
width: auto;
}
#header #header-main-bar .board-header-btns.left {
float: inline-start;
}
#header #header-main-bar .board-header-btns.right {
float: inline-end;
/* Always hug the right edge (and, if the row wraps, the right of its row). */
margin-inline-start: auto;
}
#header #header-main-bar .board-header-btn {
border-radius: 3px;
@@ -60,6 +70,15 @@
line-height: 28px;
margin: 0 12px;
}
/* The sidebar hamburger sits at the far right; drop its wide 12px side margins so
there is no button-sized empty space to its left or right. The cog before it also
drops its right margin so the two sit snugly together at the edge. */
#header #header-main-bar .board-header-btn.js-toggle-sidebar {
margin-inline-start: 2px;
/* 8px to the right edge — the same small gap as to the right of the user name in
the top bar (.header-user-bar-name margin-inline-end). */
margin-inline-end: 8px;
}
#header #header-main-bar .board-header-btn i.fa {
float: inline-start;
display: block;
@@ -290,15 +309,19 @@ body:not(.board-view) #header #header-main-bar h1 {
}
/* Zoom Controls in Header */
/* #6465: compact fixed-pixel pill. The vh/vw padding+shadow made the pill
taller than the 28px overflow-hidden quick-access row, so it rendered cut
off at every window size. */
#header-quick-access .zoom-controls {
display: flex;
align-items: center;
gap: 0.5vw;
gap: 4px;
background: rgba(255, 255, 255, 0.9);
padding: 0.5vh 1vw;
border-radius: 0.5vw;
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
margin: 0 1vw;
padding: 2px 8px;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
margin: 0 8px;
max-height: 24px;
float: inline-start;
}
+14 -13
View File
@@ -5,7 +5,7 @@ template(name="header")
Reddit "subreddit" bar.
The first link goes to the boards page.
if currentUser
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
#header-quick-access(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}" role="navigation" aria-label="{{_ 'all-boards'}}")
// Home icon - always at left side of logo
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
@@ -24,11 +24,13 @@ template(name="header")
div#headerIsSettingDatabaseCallDone
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
// Zoom controls - always visible
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
// Mobile/desktop mode toggle (swapped with zoom)
.mobile-mode-toggle
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
i.fa.fa-mobile
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
i.fa.fa-desktop
// Drag handles toggle - between zoom and mobile mode toggle
a.board-header-btn.js-toggle-desktop-drag-handles(title="{{_ 'show-desktop-drag-handles'}}")
@@ -78,12 +80,11 @@ template(name="header")
//
i.fa.fa-plus(title="Create a new board")
.mobile-mode-toggle
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
i.fa.fa-mobile
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
i.fa.fa-desktop
// Zoom controls (swapped with the mobile/desktop toggle)
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
// Notifications
+notifications
@@ -95,7 +96,7 @@ template(name="header")
+headerUserBar
#header(class=currentBoard.colorClass)
#header(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}")
//-
The main bar is a colorful bar that provide all the meta-data for the
current page. This bar is contextual based.
+38 -7
View File
@@ -157,8 +157,14 @@ input,
select,
textarea,
button {
font: clamp(14px, 2.5vw, 18px) Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 1.4;
/* #6465: back to the WeKan 6.09 desktop density. The clamp() introduced in
v7.98 (20e9cf114) and raised in v8.02 (67b078b80) resolved to 18px on any
window wider than 720px — +28.5% on EVERY font, input and button versus
6.09's 14px — which is the "everything is way too big" regression. Mobile
readability is handled by the mobile media queries, not by inflating the
desktop base font. */
font: 14px Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 18px;
color: #4d4d4d;
/* Improve text rendering */
-webkit-font-smoothing: antialiased;
@@ -217,8 +223,10 @@ body.mobile-mode #content {
margin-top: 30vh;
}
#content > .wrapper {
margin-top: 1vh;
padding: 2vh 2vw;
/* #6465: 6.09 values; 2vh/2vw padding was ~21x38px on FullHD — the huge
empty header band on the All Boards page. */
margin-top: 10px;
padding: 15px;
}
#modal {
position: absolute;
@@ -264,13 +272,36 @@ body.mobile-mode #content {
float: inline-end;
font-size: clamp(18px, 4vw, 24px);
}
/* Archived boards modal: full-width panel that extends to the bottom of the browser
window (so archived boards show full width with maximum vertical space). It is the
only modal that contains .archived-lists. */
#modal .modal-content:has(.archived-lists) {
width: 96vw;
max-width: 96vw;
/* The header bar(s) sit above the modal (higher z-index), so start the archive
panel BELOW them — otherwise its top (and the close X) is hidden under the top
header bar. ~96px clears both the quick-access bar and the board header bar; on
the All Boards page (one bar) this leaves a small top gap. Then extend to the
bottom of the browser window. */
margin: 96px auto 0;
min-height: calc(100vh - 106px);
box-sizing: border-box;
}
/* Each archived board is a full-width row. */
#modal .modal-content .archived-lists,
#modal .modal-content .archived-lists .archived-lists-item {
width: 100%;
box-sizing: border-box;
}
/* #6465: heading sizes back to the 6.09 values (22/18/16px) — the vw clamps
resolved to 24/20/18px on desktop and compounded the density regression. */
h1 {
font-size: clamp(18px, 4vw, 24px);
font-size: 22px;
line-height: 1.2em;
margin: 0 0 1vh;
}
h2 {
font-size: clamp(16px, 3.5vw, 20px);
font-size: 18px;
line-height: 1.2em;
margin: 0 0 0.8vh;
}
@@ -278,7 +309,7 @@ h3,
h4,
h5,
h6 {
font-size: clamp(14px, 3vw, 18px);
font-size: 16px;
line-height: 1.25em;
margin: 0 0 0.6vh;
}
+13 -1
View File
@@ -31,7 +31,10 @@ Template.userFormsLayout.onCreated(function () {
Meteor.call('isOidcRedirectionEnabled', (_, result) => {
if (result) {
AccountsTemplates.options.socialLoginStyle = 'redirect';
options = {
// #5695: this is an ES module, so the body runs in strict mode and
// the previous undeclared `options = {...}` assignment threw a
// ReferenceError, breaking the OIDC_REDIRECTION_ENABLED auto-login.
const options = {
loginStyle: AccountsTemplates.options.socialLoginStyle,
};
Meteor.loginWithOidc(options);
@@ -83,14 +86,23 @@ Template.userFormsLayout.onRendered(() => {
})();
});
// #4394 (security): a one-shot $('.at-signup-link').hide() does not survive
// the useraccounts form re-rendering that happens after a failed login (e.g.
// an LDAP "User not Found") — the links were recreated without the inline
// display:none and reappeared even though registration / forgot-password
// were disabled in the Admin Panel. Toggle a class on the stable <body>
// ancestor and hide the links via CSS (userForm.css) so the rule keeps
// applying to every freshly re-rendered copy of the links.
Meteor.call('isDisableRegistration', (_, result) => {
if (result) {
document.body.classList.add('registration-disabled');
$('.at-signup-link').hide();
}
});
Meteor.call('isDisableForgotPassword', (_, result) => {
if (result) {
document.body.classList.add('forgotpwd-disabled');
$('.at-pwd-link').hide();
}
});
+1 -1
View File
@@ -94,7 +94,7 @@ template(name="myCards")
td
.div
each label in card.labelIds
span.card-label(class="card-label-{{labelColor board label}}" title=name)
span.card-label(class="{{labelColorClass (labelColor board label)}}" style="{{labelColorStyle (labelColor board label)}}" title=name)
| {{labelName board label}}
td
if card.dueAt
+10 -2
View File
@@ -171,6 +171,12 @@
overflow: visible;
height: auto;
flex: 1;
/* Auto-filling columns so as many languages as possible are visible at once when
there is browser width; collapses to a single column on narrow windows. */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
align-content: start;
column-gap: 4px;
}
/* Ensure content div in language popup contains all items */
@@ -197,8 +203,10 @@
top: auto !important;
inset-inline-start: auto !important;
inset-inline-end: 20px !important;
width: auto !important;
max-width: 450px !important;
/* Wide enough for multiple language columns when the browser is wide, but never
wider than ~90% of the viewport (falls back to a single narrow column). */
width: min(90vw, 1100px) !important;
max-width: 90vw !important;
height: 100vh !important;
max-height: 100vh !important;
min-height: 300px !important;
+7 -1
View File
@@ -9,7 +9,13 @@ Popup.template.events({
Popup.close();
},
'click .js-confirm'() {
this.__afterConfirmAction.call(this);
// #6479: the action is stored on the Popup instance (see Popup.afterConfirm),
// not on this data context, so a re-rendered/immutable context can't lose it.
// Fall back to the legacy per-context field for any external caller.
const action = Popup._afterConfirmAction || this.__afterConfirmAction;
if (typeof action === 'function') {
action.call(this);
}
},
// #5942: On mobile/touch, tapping inside the card-detail sub-popups (assign
// user / set due date) made the popup DISAPPEAR. The document-level
+15
View File
@@ -0,0 +1,15 @@
import { Template } from 'meteor/templating';
const { cleanFileName, sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
// Global helpers for showing filenames safely EVERYWHERE (card attachments, admin
// panel, etc.). No template needed — a filename is always shown as a plain, clean
// string:
// {{cleanFilename name}} -> URL-decoded, invisible chars removed, exploit
// markup (HTML/JS/XML) removed, whitespace collapsed
// {{downloadFilename name}} -> the same, but never empty (falls back to "download")
if (!Template.__safeFilenameHelpersRegistered) {
Template.registerHelper('cleanFilename', name => cleanFileName(name));
Template.registerHelper('downloadFilename', name => sanitizeDownloadFileName(name));
Template.__safeFilenameHelpersRegistered = true;
}
@@ -0,0 +1,32 @@
//- Shared "Select Color" picker (docs/Theme/Theme.md), used by both Board Settings
and Member Settings. Colors are shown as VISIBLE swatches grouped by category, with
the category name as a header above each group; the native color wheel(s) appear
for the flat (1) / clear (2) categories once such a color is selected. `data.scope`
is 'board' or 'global'.
template(name="themeColorPicker")
form.theme-color-picker.edit-label
if isGlobal
ul.pop-over-list
li(class="{{#if isNoneSelected}}active{{/if}}")
a.js-theme-none
i.fa.fa-ban
| {{_ 'theme-default'}}
if isNoneSelected
i.fa.fa-check
each themeGroups
.theme-category-group
.theme-category-label {{label}}
.board-backgrounds-list.clearfix
each colors
.board-background-select.js-select-theme(data-color="{{name}}")
span.background-box(class="board-color-{{name}}")
span {{name}}
if selected
i.fa.fa-check
if showCustom
.color-wheel-wrap
label {{_ 'custom-color'}}
each customWheels
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
.theme-preview
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
+166
View File
@@ -0,0 +1,166 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { BOARD_COLORS } from '/models/metadata/colors';
import {
THEME_CATEGORY_ORDER,
categoryOf,
colorsInCategory,
allowsCustomColor,
customColorCount,
} from '/models/lib/themeCategories';
// Shared "Select Color" picker (docs/Theme/Theme.md): VISIBLE color swatches grouped
// by category (with the category name above each group) + native color wheel(s) for
// the flat/clear categories once such a color is selected. data.scope is 'board'
// (writes board.color) or 'global' (writes the user's #5778 global override).
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
function readCurrent(scope) {
if (scope === 'board') {
const b = Utils.getCurrentBoard();
return { color: (b && b.color) || BOARD_COLORS[0], custom: (b && b.customThemeColors) || [] };
}
const u = ReactiveCache.getCurrentUser();
return {
color: (u && u.getGlobalThemeColor && u.getGlobalThemeColor()) || null,
custom: (u && u.getGlobalThemeCustomColors && u.getGlobalThemeCustomColors()) || [],
};
}
Template.themeColorPicker.onCreated(function () {
const scope = this.data && this.data.scope === 'board' ? 'board' : 'global';
const cur = readCurrent(scope);
this.scope = scope;
this.color = new ReactiveVar(cur.color); // null for global = no override
this.customColors = new ReactiveVar((cur.custom || []).slice());
});
Template.themeColorPicker.helpers({
isGlobal() {
return Template.instance().scope === 'global';
},
isNoneSelected() {
const tpl = Template.instance();
return tpl.scope === 'global' && !tpl.color.get();
},
// Visible swatches grouped by category, each group labelled with its category name.
themeGroups() {
const cur = Template.instance().color.get();
return THEME_CATEGORY_ORDER.map(key => ({
key,
label: TAPi18n.__(`theme-category-${key}`),
colors: colorsInCategory(key).map(name => ({ name, selected: name === cur })),
}));
},
showCustom() {
const cur = Template.instance().color.get();
return cur ? allowsCustomColor(categoryOf(cur)) : false;
},
customWheels() {
const tpl = Template.instance();
const cur = tpl.color.get();
const n = cur ? customColorCount(categoryOf(cur)) : 0;
const cc = tpl.customColors.get();
const wheels = [];
for (let i = 0; i < n; i += 1) {
wheels.push({ index: i, value: toHex(cc[i]) || DEFAULT_WHEEL[i] || '#0079bf' });
}
return wheels;
},
previewClass() {
const color = Template.instance().color.get();
return color ? `board-color-${color}` : '';
},
// Inline preview of custom colors (the stock board-color-* CSS can't show arbitrary
// colors until the CSS-variable refactor; the preview swatch shows them directly).
previewStyle() {
const tpl = Template.instance();
const cur = tpl.color.get();
const cat = cur ? categoryOf(cur) : null;
if (!cat || !allowsCustomColor(cat)) return '';
const cc = tpl.customColors.get();
if (!cc.some(Boolean)) return '';
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
}
if (cc[0]) return `background: ${cc[0]} !important;`;
return '';
},
});
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
// which case read every wheel's current value (untouched ones contribute their shown
// default) so the result is a complete set of the category's expected count.
function gatherCustom(tpl) {
const color = tpl.color.get();
const cat = color ? categoryOf(color) : null;
if (!cat || !allowsCustomColor(cat)) return [];
if (!tpl.customColors.get().some(Boolean)) return [];
const n = customColorCount(cat);
const wheels = Array.from(tpl.findAll('.js-theme-wheel'))
.map(w => w.value)
.filter(isHexColor)
.slice(0, n);
return wheels.length === n ? wheels : [];
}
// Apply the current selection immediately (no Save button): board scope writes
// board.color, global scope calls setGlobalThemeColor. `color`/`custom` are read
// from the reactive state so the same helper serves the swatch click and the wheel.
function applySelection(tpl) {
const color = tpl.color.get() || colorsInCategory(THEME_CATEGORY_ORDER[0])[0];
const custom = gatherCustom(tpl);
if (tpl.scope === 'board') {
const b = Utils.getCurrentBoard();
if (b) {
Promise.resolve(b.setColor(color, custom)).catch(e => {
if (process.env.DEBUG === 'true') console.error('board setColor error', e);
});
}
} else {
Meteor.call('setGlobalThemeColor', color, custom, err => {
if (err && process.env.DEBUG === 'true') console.error('setGlobalThemeColor error', err);
});
}
}
Template.themeColorPicker.events({
// Clicking a color swatch applies that theme IMMEDIATELY (there is no Save button).
'click .js-select-theme'(event, tpl) {
const color = event.currentTarget.dataset.color;
if (!color) return;
tpl.color.set(color);
tpl.customColors.set([]); // a fresh theme -> its stock colors
applySelection(tpl);
},
// Live-update the preview while dragging the wheel...
'input .js-theme-wheel'(event, tpl) {
const idx = parseInt(event.currentTarget.dataset.index, 10);
const val = event.currentTarget.value;
if (!isHexColor(val)) return;
const cc = tpl.customColors.get().slice();
cc[idx] = val;
tpl.customColors.set(cc);
},
// ...and apply the custom color when the wheel is committed (avoids spamming the
// server on every intermediate value during the drag).
'change .js-theme-wheel'(event, tpl) {
applySelection(tpl);
},
// Clear the global override (Default) — applies immediately too.
'click .js-theme-none'(event, tpl) {
event.preventDefault();
tpl.color.set(null);
tpl.customColors.set([]);
if (tpl.scope === 'board') {
const b = Utils.getCurrentBoard();
if (b) b.setColor(BOARD_COLORS[0], []);
} else {
Meteor.call('setGlobalThemeColor', null, null);
}
},
});
+40
View File
@@ -0,0 +1,40 @@
/* Member Settings / Font (#4759): apply the user's chosen UI font. The class lives
* on <body> and the --wekan-ui-font variable is set by uiFont.js only for a
* validated, whitelisted font. Form controls are listed explicitly because their
* UA default font-family does not inherit. Icon fonts (Font Awesome etc.) set their
* own font-family on the element, so they are unaffected. */
body.has-ui-font,
body.has-ui-font button,
body.has-ui-font input,
body.has-ui-font textarea,
body.has-ui-font select,
body.has-ui-font .wekan-form-control {
font-family: var(--wekan-ui-font) !important;
}
/* Font size preset (#4759): scale the root font size. Elements using relative units
* (em/rem/%) scale with it; the variable is a percentage set by uiFont.js only for a
* known preset. 'default'/unset removes it. */
body.has-ui-font-size {
font-size: var(--wekan-ui-font-size) !important;
}
/* Text color (#4759): the variable is a validated hex set by uiFont.js. Icon fonts
* keep their own color rules; this recolors ordinary UI text + form controls. */
body.has-ui-text-color,
body.has-ui-text-color button,
body.has-ui-text-color input,
body.has-ui-text-color textarea,
body.has-ui-text-color select,
body.has-ui-text-color .wekan-form-control {
color: var(--wekan-ui-text-color) !important;
}
/* Text background color (#4759). */
body.has-ui-bg-color,
body.has-ui-bg-color input,
body.has-ui-bg-color textarea,
body.has-ui-bg-color select,
body.has-ui-bg-color .wekan-form-control {
background-color: var(--wekan-ui-bg-color) !important;
}
+34
View File
@@ -0,0 +1,34 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { fontFamilyValue, fontSizeValue, colorValue } from '/models/lib/uiFonts';
// #4759: apply the user's chosen UI font AND font-size preset to the whole UI by
// setting CSS variables on :root and toggling marker classes on <body> (uiFont.css
// consumes them). fontFamilyValue()/fontSizeValue() return '' for anything not on the
// curated whitelist/presets, so only known, safe values can ever reach the DOM — the
// saved values are also validated server-side. Unset => nothing applied (defaults).
Meteor.startup(() => {
function toggle(varName, className, value) {
try {
if (value) {
document.documentElement.style.setProperty(varName, value);
document.body.classList.add(className);
} else {
document.documentElement.style.removeProperty(varName);
document.body.classList.remove(className);
}
} catch (_) {
// document not ready in exotic embeddings; ignore.
}
}
Tracker.autorun(() => {
const user = Meteor.user();
const profile = (user && user.profile) || {};
toggle('--wekan-ui-font', 'has-ui-font', fontFamilyValue(profile.uiFont));
toggle('--wekan-ui-font-size', 'has-ui-font-size', fontSizeValue(profile.uiFontSize));
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
toggle('--wekan-ui-bg-color', 'has-ui-bg-color', colorValue(profile.uiTextBgColor));
});
});
@@ -2,7 +2,8 @@ section#notifications-drawer {
position: fixed;
top: 48px;
inset-inline-end: 0;
width: 400px;
width: 100vw;
max-width: 100vw;
background-color: #fafafa;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-radius: 2px;
@@ -17,19 +18,20 @@ section#notifications-drawer .header {
position: fixed;
top: 48px;
inset-inline-end: 0;
width: calc(400px - 32px);
width: 100vw;
box-sizing: border-box;
padding: 8px 16px;
background: #ededed;
border-bottom: 1px solid #dbdbdb;
z-index: 2;
/* Flex row: hamburger (left), title (centre), X (right), all vertically centred. */
display: flex;
align-items: center;
}
section#notifications-drawer .header .notification-menu-toggle {
position: absolute;
inset-inline-start: 16px;
top: calc(50% - 12px);
font-size: 20px;
cursor: pointer;
color: #333;
color: #000 !important; /* black hamburger menu (override themed white link colour) */
line-height: 24px;
}
section#notifications-drawer .header .notification-menu-toggle:hover {
@@ -83,18 +85,33 @@ section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
margin-inline-end: 8px;
font-size: 16px;
}
/* Menu-item icons (envelope / copy / trash / check) black. */
section#notifications-drawer .header .notification-menu .menu-item .menu-icon,
section#notifications-drawer .header .notification-menu .menu-item .menu-icon i.fa,
section#notifications-drawer .header .notification-menu .menu-item .check-icon,
section#notifications-drawer .header .notification-menu .menu-item .check-icon i.fa {
color: #000 !important;
}
section#notifications-drawer .header h5 {
flex: 1; /* fill the middle so the title is centred, X sits at the right */
text-align: center;
margin: 0;
}
section#notifications-drawer .header .close {
position: absolute;
top: calc(50% - 12px);
inset-inline-end: 12px;
/* Flex item at the right edge, aligned with the title. position: static cancels
the GLOBAL `.close { position: absolute; top: 5px }` rule (boardBody.css) that
otherwise pulls the X out of the header row and drops it below the title. */
position: static;
margin-inline-start: 8px;
font-size: 24px;
height: 24px;
line-height: 24px;
opacity: 1;
color: #000 !important; /* black X (override themed white link colour) */
}
section#notifications-drawer .header .close i.fa,
section#notifications-drawer .header .notification-menu-toggle i.fa {
color: inherit !important;
}
section#notifications-drawer ul.notifications {
+43 -50
View File
@@ -4,6 +4,7 @@ import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
import { Utils } from '/client/lib/utils';
import { canSelectBoardInRules } from '/models/lib/rulesBoardSelector';
Template.boardActions.onCreated(function () {
this.subscribe('boards');
@@ -11,19 +12,30 @@ Template.boardActions.onCreated(function () {
Template.boardActions.helpers({
boards() {
const ret = ReactiveCache.getBoards(
{
archived: false,
'members.userId': Meteor.userId(),
_id: {
$ne: ReactiveCache.getCurrentUser().getTemplatesBoardId(),
},
},
// #5698: don't restrict to boards where the user is a DIRECT member — that
// hid every board reached through an Organization / Team / email-domain
// share (the reporter's whole board dropdown was empty, current board
// included). Filter the already access-scoped client cache with the same
// visibility rule as Boards.userBoards() so org/team/domain boards show too.
const user = ReactiveCache.getCurrentUser();
if (!user) return [];
const ctx = {
userId: user._id,
orgIds: typeof user.orgIds === 'function' ? user.orgIds() : [],
teamIds: typeof user.teamIds === 'function' ? user.teamIds() : [],
emailDomains:
typeof user.emailDomains === 'function' ? user.emailDomains() : [],
};
const templatesBoardId = user.getTemplatesBoardId();
const boards = ReactiveCache.getBoards(
{ archived: false },
{
sort: { sort: 1 /* boards default sorting */ },
},
);
return ret;
return boards.filter(board =>
canSelectBoardInRules(board, ctx, templatesBoardId),
);
},
loadingBoardsLabel() {
@@ -94,38 +106,19 @@ Template.boardActions.events({
const boardId = Session.get('currentBoard');
const destBoardId = tpl.find('#board-id').value;
const desc = Utils.getTriggerActionDesc(event, tpl);
if (actionSelected === 'top') {
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveCardToTop',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
}
if (actionSelected === 'bottom') {
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'moveCardToBottom',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
}
// #5536: create the rule on the server (see server/rulesButton.js →
// rules.createRule) instead of three optimistic client Collection.insert()
// calls. The optimistic writes landed in minimongo limbo once the wizard
// subscription stopped, so the cross-board move action rendered BLANK; and
// the client allow/deny rules rejected them outright for non-owner members.
// The action keeps its own destination `boardId` (the board to move to).
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType: actionSelected === 'bottom' ? 'moveCardToBottom' : 'moveCardToTop',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
},
'click .js-add-gen-move-action'(event, tpl) {
const desc = Utils.getTriggerActionDesc(event, tpl);
@@ -141,7 +134,11 @@ Template.boardActions.events({
// documents land in minimongo limbo when the wizard subscription stops.
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType,
listTitle: '*',
// #6472: was `listTitle: '*'` since the original rules implementation,
// but performAction reads action.listName/action.swimlaneName — so every
// generic "move to top/bottom" rule created by this wizard did nothing.
listName: '*',
swimlaneName: '*',
boardId,
desc,
});
@@ -191,20 +188,16 @@ Template.boardActions.events({
const boardId = Session.get('currentBoard');
const destBoardId = tpl.find('#board-id-link').value;
const desc = Utils.getTriggerActionDesc(event, tpl);
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
// #5536: create on the server so the cross-board link action does not land
// blank in minimongo limbo / get rejected by client allow/deny. The action
// keeps its own destination `boardId` (the board to link the card into).
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
actionType: 'linkCard',
listName,
swimlaneName,
boardId: destBoardId,
desc,
});
Rules.insert({
title: ruleName,
triggerId,
actionId,
boardId,
});
},
'click .js-sort-list-action'(event, tpl) {
const data = Template.currentData();
@@ -100,8 +100,15 @@ template(name="cardActions")
option(value="receivedAt") {{_'r-df-received-at'}}
div.trigger-dropdown
input(id="reldate-days",type="number",value="0")
div.trigger-dropdown
select(id="reldate-unit")
option(value="minutes") {{_'r-unit-minutes'}}
option(value="hours") {{_'r-unit-hours'}}
option(value="days",selected="selected") {{_'r-unit-days'}}
option(value="weeks") {{_'r-unit-weeks'}}
option(value="months") {{_'r-unit-months'}}
div.trigger-text
| {{_'r-days-after'}}
| {{_'r-later'}}
div.trigger-button.js-set-reldate-action.js-goto-rules
i.fa.fa-plus
@@ -238,12 +238,16 @@ Template.cardActions.events({
const boardId = Session.get('currentBoard');
const desc = Utils.getTriggerActionDesc(event, tpl);
const dateField = tpl.find('#reldate-datefield').value;
// `days` keeps its name for backward compatibility: it is the numeric
// amount, interpreted together with `unit` (minutes/hours/days/weeks/months).
const days = parseInt(tpl.find('#reldate-days').value, 10) || 0;
const unit = tpl.find('#reldate-unit').value;
const triggerId = Triggers.insert(trigger);
const actionId = Actions.insert({
actionType: 'setDateRelative',
dateField,
days,
unit,
boardId,
desc,
});
+32 -6
View File
@@ -1,9 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import Papa from 'papaparse';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
const RULES_FORMAT = 'wekan-rules-1.0.0';
const STRIP_FIELDS = ['_id', 'boardId', 'createdAt', 'modifiedAt', 'updatedAt'];
@@ -33,14 +30,43 @@ function collectBoardRules(boardId) {
.filter(Boolean);
}
// #6472: the trigger matcher (server/rulesHelper.js buildMatchingFieldsMap)
// queries every matching field of the trigger type with {$in: [value, '*']} —
// a trigger DOCUMENT that lacks one of those fields can never match. Hand-
// written or third-party JSON often omits fields like userId, so default every
// known matching field to the '*' wildcard when absent. Extra fields on
// trigger types that do not use them are harmless (never queried).
const TRIGGER_MATCHING_FIELDS = [
'userId', 'username', 'cardTitle', 'listName', 'oldListName',
'swimlaneName', 'checklistName', 'checklistItemName', 'labelId',
'attachmentName',
];
function normalizeTrigger(trigger) {
const out = { ...trigger };
TRIGGER_MATCHING_FIELDS.forEach(f => {
if (out[f] === undefined || out[f] === null || out[f] === '') out[f] = '*';
});
return out;
}
// Insert an array of {title, trigger, action} onto the given target board.
function importRules(rulesArray, boardId) {
let count = 0;
(rulesArray || []).forEach(entry => {
if (!entry || !entry.trigger || !entry.action) return;
const triggerId = Triggers.insert({ ...stripDoc(entry.trigger), boardId });
const actionId = Actions.insert({ ...stripDoc(entry.action), boardId });
Rules.insert({ title: entry.title || 'Imported rule', triggerId, actionId, boardId });
// #6472: create via the rules.createRule server method (like the rules
// wizard since #5536) instead of three optimistic client inserts — those
// are rejected by the board-admin-only allow() rules for non-admins and
// land in minimongo limbo: the imported rule LOOKS created but never
// exists on the server, so it silently does nothing until it vanishes.
Meteor.call(
'rules.createRule',
boardId,
entry.title || 'Imported rule',
normalizeTrigger(stripDoc(entry.trigger)),
stripDoc(entry.action),
);
count += 1;
});
return count;
+3 -3
View File
@@ -30,14 +30,14 @@ template(name="rulesList")
else
= title
div.rules-btns-group
button.primary.wide.js-goto-details(type="submit")
button.primary.wide.js-goto-details(type="submit" data-rule-id="{{_id}}")
i.fa.fa-eye
| {{_ 'r-view-rule'}}
if canAdmin
button.primary.wide.js-edit-rule(type="submit")
button.primary.wide.js-edit-rule(type="submit" data-rule-id="{{_id}}")
i.fa.fa-pencil
| {{_ 'r-edit-rule'}}
button.primary.wide.js-delete-rule(type="submit")
button.primary.wide.js-delete-rule(type="submit" data-rule-id="{{_id}}")
i.fa.fa-trash
| {{_ 'r-delete-rule'}}
else
+4 -5
View File
@@ -1,7 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
function boardRuleIds() {
const boardId = Session.get('currentBoard');
@@ -62,9 +60,10 @@ Template.rulesList.events({
getSelected().forEach(ruleId => {
const rule = ReactiveCache.getRule(ruleId);
if (rule) {
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
// Delete the rule + its trigger + action server-side (see
// server/rulesButton.js `rules.deleteRule`): three client-side
// Collection.remove() calls were rejected with 403 "Access denied".
Meteor.call('rules.deleteRule', rule._id);
}
});
setSelected([]);
+20 -10
View File
@@ -1,9 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Utils } from '/client/lib/utils';
import Actions from '/models/actions';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
Template.rulesMain.onCreated(function () {
this.rulesCurrentTab = new ReactiveVar('rulesList');
@@ -81,11 +78,20 @@ function sanitizeObject(obj) {
}
Template.rulesMain.events({
'click .js-delete-rule'() {
const rule = Template.currentData();
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
'click .js-delete-rule'(event) {
// #6490: the button lives in the rulesList child template's `each`, but this
// handler is on the parent rulesMain, so Template.currentData() returned the
// PARENT's data context (not the clicked rule) — the rule id resolved to null,
// so delete failed server-side with "Match error: Expected string, got null".
// Read the rule id from the button's explicit data-rule-id instead.
const ruleId = event.currentTarget.getAttribute('data-rule-id');
if (!ruleId) return;
// Delete the rule + its trigger + action in one server call. Doing this
// client-side as three separate Collection.remove() calls failed with 403
// "Access denied" whenever a trigger/action document's boardId did not
// resolve to a board in the allow() rule; the method authorizes once and
// removes all three server-side.
Meteor.call('rules.deleteRule', ruleId);
},
'click .js-goto-trigger'(event, tpl) {
event.preventDefault();
@@ -141,8 +147,12 @@ Template.rulesMain.events({
},
'click .js-goto-details'(event, tpl) {
event.preventDefault();
const rule = Template.currentData();
tpl.ruleId.set(rule._id);
// #6490: same as delete — Template.currentData() returned the parent context
// here, so "View rule" always opened the same (first) rule regardless of which
// one was clicked. Read the clicked rule's id from its data-rule-id.
const ruleId = event.currentTarget.getAttribute('data-rule-id');
if (!ruleId) return;
tpl.ruleId.set(ruleId);
tpl.rulesCurrentTab.set('ruleDetails');
},
});
+2 -2
View File
@@ -12,7 +12,7 @@ template(name="rulesWorkflow")
.workflow-node.workflow-node-when
span.workflow-node-label {{_ 'r-when'}}
span.workflow-node-text= builderTriggerLabel
a.workflow-slot-clear.js-clear-when(title="{{_ 'remove'}}") &#10005;
a.workflow-slot-clear.js-clear-when(title="{{_ 'r-remove'}}") &#10005;
else
span.workflow-slot-placeholder {{_ 'r-when'}}: {{_ 'r-drop-trigger'}}
.workflow-arrow
@@ -22,7 +22,7 @@ template(name="rulesWorkflow")
.workflow-node.workflow-node-then
span.workflow-node-label {{_ 'r-action'}}
span.workflow-node-text= builderActionLabel
a.workflow-slot-clear.js-clear-then(title="{{_ 'remove'}}") &#10005;
a.workflow-slot-clear.js-clear-then(title="{{_ 'r-remove'}}") &#10005;
else
span.workflow-slot-placeholder {{_ 'r-action'}}: {{_ 'r-drop-action'}}
.workflow-builder-controls
+47 -32
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import Actions from '/models/actions';
import Rules from '/models/rules';
@@ -12,31 +13,44 @@ import Triggers from '/models/triggers';
// can be built entirely by dragging. Parameterized rules (specific list, label,
// member, schedule time, …) are still created with the form builder on the
// List view.
// Each palette entry carries an i18n `labelKey` (translated at render time via
// TAPi18n.__) instead of a hardcoded English string, so the whole workflow view
// follows the UI language. `labelParams` is passed to TAPi18n.__ for labels that
// need a placeholder (e.g. the scheduled time). Existing rule keys are reused
// where they fit; workflow-only labels use new `r-w-*` keys (see en.i18n.json).
const TRIGGER_PALETTE = [
{ label: 'Card is created', doc: { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ label: 'Card is moved', doc: { activityType: 'moveCard', listName: '*', oldListName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ label: 'Card is archived', doc: { activityType: 'archivedCard', userId: '*' } },
{ label: 'Card is unarchived', doc: { activityType: 'restoredCard', userId: '*' } },
{ label: 'Any label is added', doc: { activityType: 'addedLabel', labelId: '*', userId: '*' } },
{ label: 'Any label is removed', doc: { activityType: 'removedLabel', labelId: '*', userId: '*' } },
{ label: 'A member is added', doc: { activityType: 'joinMember', username: '*', userId: '*' } },
{ label: 'A member is removed', doc: { activityType: 'unjoinMember', username: '*', userId: '*' } },
{ label: 'A checklist is added', doc: { activityType: 'addChecklist', checklistName: '*', userId: '*' } },
{ label: 'An attachment is added', doc: { activityType: 'addAttachment', userId: '*' } },
{ label: 'Every day at 09:00', doc: { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' } },
{ labelKey: 'r-w-card-created', doc: { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ labelKey: 'r-when-a-card-is-moved', doc: { activityType: 'moveCard', listName: '*', oldListName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' } },
{ labelKey: 'r-w-card-archived', doc: { activityType: 'archivedCard', userId: '*' } },
{ labelKey: 'r-w-card-unarchived', doc: { activityType: 'restoredCard', userId: '*' } },
{ labelKey: 'r-w-label-added', doc: { activityType: 'addedLabel', labelId: '*', userId: '*' } },
{ labelKey: 'r-w-label-removed', doc: { activityType: 'removedLabel', labelId: '*', userId: '*' } },
{ labelKey: 'r-w-member-added', doc: { activityType: 'joinMember', username: '*', userId: '*' } },
{ labelKey: 'r-w-member-removed', doc: { activityType: 'unjoinMember', username: '*', userId: '*' } },
{ labelKey: 'r-w-checklist-added', doc: { activityType: 'addChecklist', checklistName: '*', userId: '*' } },
{ labelKey: 'r-w-attachment-added', doc: { activityType: 'addAttachment', userId: '*' } },
{ labelKey: 'r-w-every-day-at', labelParams: { time: '09:00' }, doc: { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' } },
];
const ACTION_PALETTE = [
{ label: 'Move card to top', doc: { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' } },
{ label: 'Move card to bottom', doc: { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' } },
{ label: 'Archive card', doc: { actionType: 'archive' } },
{ label: 'Unarchive card', doc: { actionType: 'unarchive' } },
{ label: 'Mark card complete', doc: { actionType: 'markCardComplete' } },
{ label: 'Mark card incomplete', doc: { actionType: 'markCardIncomplete' } },
{ label: 'Remove all members', doc: { actionType: 'removeMember', username: '*' } },
{ label: 'Set received date to now', doc: { actionType: 'setDate', dateField: 'receivedAt' } },
{ labelKey: 'r-d-move-to-top-gen', doc: { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' } },
{ labelKey: 'r-d-move-to-bottom-gen', doc: { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' } },
{ labelKey: 'r-d-archive', doc: { actionType: 'archive' } },
{ labelKey: 'r-d-unarchive', doc: { actionType: 'unarchive' } },
{ labelKey: 'r-mark-complete', doc: { actionType: 'markCardComplete' } },
{ labelKey: 'r-mark-incomplete', doc: { actionType: 'markCardIncomplete' } },
{ labelKey: 'r-remove-all', doc: { actionType: 'removeMember', username: '*' } },
{ labelKey: 'r-w-set-received-now', doc: { actionType: 'setDate', dateField: 'receivedAt' } },
];
// Translate a palette entry's label for display / storage in the current UI
// language (matches the classic Rules view, which also stores already-translated
// description text at creation time).
function paletteLabel(entry) {
if (!entry) return '';
return TAPi18n.__(entry.labelKey, entry.labelParams);
}
Template.rulesWorkflow.onCreated(function () {
this.builderTrigger = new ReactiveVar(null);
this.builderAction = new ReactiveVar(null);
@@ -52,24 +66,22 @@ Template.rulesWorkflow.helpers({
return Utils.getCurrentBoard();
},
triggerPalette() {
return TRIGGER_PALETTE.map((t, idx) => ({ idx, label: t.label }));
return TRIGGER_PALETTE.map((t, idx) => ({ idx, label: paletteLabel(t) }));
},
actionPalette() {
return ACTION_PALETTE.map((a, idx) => ({ idx, label: a.label }));
return ACTION_PALETTE.map((a, idx) => ({ idx, label: paletteLabel(a) }));
},
builderTrigger() {
return Template.instance().builderTrigger.get();
},
builderTriggerLabel() {
const t = Template.instance().builderTrigger.get();
return t ? t.label : '';
return paletteLabel(Template.instance().builderTrigger.get());
},
builderAction() {
return Template.instance().builderAction.get();
},
builderActionLabel() {
const a = Template.instance().builderAction.get();
return a ? a.label : '';
return paletteLabel(Template.instance().builderAction.get());
},
createDisabled() {
const tpl = Template.instance();
@@ -96,9 +108,11 @@ function persistRule(tpl) {
if (!t || !a) return;
const boardId = Session.get('currentBoard');
const titleField = tpl.find('.js-workflow-rule-title');
const title = (titleField.value || '').trim() || `${t.label} → ${a.label}`;
const triggerId = Triggers.insert({ ...t.doc, boardId, desc: t.label });
const actionId = Actions.insert({ ...a.doc, boardId, desc: a.label });
const tLabel = paletteLabel(t);
const aLabel = paletteLabel(a);
const title = (titleField.value || '').trim() || `${tLabel} → ${aLabel}`;
const triggerId = Triggers.insert({ ...t.doc, boardId, desc: tLabel });
const actionId = Actions.insert({ ...a.doc, boardId, desc: aLabel });
Rules.insert({ title, triggerId, actionId, boardId });
tpl.builderTrigger.set(null);
tpl.builderAction.set(null);
@@ -140,7 +154,7 @@ Template.rulesWorkflow.events({
const a = ACTION_PALETTE[tpl.dragItem.idx];
const boardId = Session.get('currentBoard');
const oldActionId = rule.actionId;
const newActionId = Actions.insert({ ...a.doc, boardId, desc: a.label });
const newActionId = Actions.insert({ ...a.doc, boardId, desc: paletteLabel(a) });
Rules.update(ruleId, { $set: { actionId: newActionId } });
if (oldActionId) Actions.remove(oldActionId);
tpl.dragItem = null;
@@ -163,8 +177,9 @@ Template.rulesWorkflow.events({
const ruleId = node && node.dataset.ruleId;
const rule = ReactiveCache.getRule(ruleId);
if (!rule) return;
Rules.remove(rule._id);
Actions.remove(rule.actionId);
Triggers.remove(rule.triggerId);
// Delete the rule + its trigger + action server-side (see
// server/rulesButton.js `rules.deleteRule`): three client-side
// Collection.remove() calls were rejected with 403 "Access denied".
Meteor.call('rules.deleteRule', rule._id);
},
});
@@ -106,6 +106,9 @@ template(name="boardCardTitlePopup")
form
label
| {{_ 'boardCardTitlePopup-title'}}
input.js-card-filter-name(type="text" value=title autofocus)
//- #2345: cardTitleFilter is passed as the popup's data context by the
//- js-open-card-title-popup handler so reopening the popup shows the
//- filter that is already set (it used to always render empty).
input.js-card-filter-name(type="text" value=cardTitleFilter autofocus)
input.js-card-filter-button.primary.wide(type="submit" value="{{_ 'set-filter'}}")
@@ -1,4 +1,31 @@
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import {
cardTitleFilterOrWildcard,
appendCardTitleFilterToDesc,
} from '/models/lib/ruleCardTitleFilter';
// #2345: fallback reference to the live boardTriggers template instance, so the
// card-title-filter popup can still store its value even if the Blaze
// data-context walk in Popup.getOpenerComponent() does not resolve (same
// module-level pattern as cardActions.js' sharedCardColorButtonValue).
let activeBoardTriggersTemplate = null;
// #2345: the trigger description is assembled from the wizard's DOM text, which
// never includes the card-title filter (it lives in a popup). Append it so the
// created rule's details actually show the filter.
function descWithCardTitleFilter(desc, cardTitle) {
let label = 'card title filter';
try {
const translated = TAPi18n.__('boardCardTitlePopup-title');
if (translated && translated !== 'boardCardTitlePopup-title') {
label = translated.toLowerCase();
}
} catch (e) {
// keep the English fallback label
}
return appendCardTitleFilterToDesc(desc, cardTitle, label);
}
Template.boardTriggers.onCreated(function () {
this.provaVar = new ReactiveVar('');
@@ -7,6 +34,13 @@ Template.boardTriggers.onCreated(function () {
this.setNameFilter = (name) => {
this.cardTitleFilters[this.currentPopupTriggerId] = name;
};
activeBoardTriggersTemplate = this;
});
Template.boardTriggers.onDestroyed(function () {
if (activeBoardTriggersTemplate === this) {
activeBoardTriggersTemplate = null;
}
});
Template.boardTriggers.events({
@@ -14,16 +48,24 @@ Template.boardTriggers.events({
const funct = Popup.open('boardCardTitle');
const divId = $(event.currentTarget.parentNode.parentNode).attr('id');
tpl.currentPopupTriggerId = divId;
funct.call(this, event);
// #2345: hand the already-stored filter to the popup as its data context so
// reopening it shows the current value instead of an empty input.
funct.call({ cardTitleFilter: tpl.cardTitleFilters[divId] || '' }, event);
},
'click .js-add-create-trigger'(event, tpl) {
const desc = Utils.getTriggerActionDesc(event, tpl);
const datas = Template.currentData();
const listName = tpl.find('#create-list-name').value;
const swimlaneName = tpl.find('#create-swimlane-name').value;
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
const cardTitle = tpl.cardTitleFilters[divId];
// #2345: an unset/empty filter is stored as the '*' wildcard so the trigger
// document always carries a cardTitle field (the server matches with
// cardTitle: { $in: [...] }, which a document missing the field never satisfies).
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
// move to generic funciont
datas.triggerVar.set({
activityType: 'createCard',
@@ -36,13 +78,16 @@ Template.boardTriggers.events({
},
'click .js-add-moved-trigger'(event, tpl) {
const datas = Template.currentData();
const desc = Utils.getTriggerActionDesc(event, tpl);
const swimlaneName = tpl.find('#create-swimlane-name-2').value;
const actionSelected = tpl.find('#move-action').value;
const listName = tpl.find('#move-list-name').value;
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
const cardTitle = tpl.cardTitleFilters[divId];
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
if (actionSelected === 'moved-to') {
datas.triggerVar.set({
activityType: 'moveCard',
@@ -68,12 +113,22 @@ Template.boardTriggers.events({
},
'click .js-add-gen-moved-trigger'(event, tpl) {
const datas = Template.currentData();
const desc = Utils.getTriggerActionDesc(event, tpl);
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
// #2345: this trigger ignored the card-title-filter popup entirely AND
// stored no cardTitle field at all — and since the server matches triggers
// with cardTitle: { $in: [...] }, a moveCard trigger without the field
// never fired for any card. Store the filter (or the '*' wildcard).
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
datas.triggerVar.set({
activityType: 'moveCard',
boardId,
cardTitle,
swimlaneName: '*',
listName: '*',
oldListName: '*',
@@ -82,13 +137,20 @@ Template.boardTriggers.events({
},
'click .js-add-arch-trigger'(event, tpl) {
const datas = Template.currentData();
const desc = Utils.getTriggerActionDesc(event, tpl);
const actionSelected = tpl.find('#arch-action').value;
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
// #2345: the archive/unarchive trigger also dropped the card-title filter.
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
if (actionSelected === 'archived') {
datas.triggerVar.set({
activityType: 'archivedCard',
boardId,
cardTitle,
desc,
});
}
@@ -96,6 +158,7 @@ Template.boardTriggers.events({
datas.triggerVar.set({
activityType: 'restoredCard',
boardId,
cardTitle,
desc,
});
}
@@ -104,15 +167,27 @@ Template.boardTriggers.events({
Template.boardCardTitlePopup.events({
submit(event) {
// #2345: preventDefault FIRST — if anything below throws, a real form
// submit would reload the page and silently drop the filter.
event.preventDefault();
const title = $(event.target)
.find('.js-card-filter-name')
.val()
.trim();
const opener = Popup.getOpenerComponent();
if (opener?.setNameFilter) {
let opener = null;
try {
opener = Popup.getOpenerComponent();
} catch (e) {
opener = null;
}
if (!opener || typeof opener.setNameFilter !== 'function') {
// #2345: fall back to the live boardTriggers instance when the Blaze
// data-context walk cannot resolve the opener.
opener = activeBoardTriggersTemplate;
}
if (opener && typeof opener.setNameFilter === 'function') {
opener.setNameFilter(title);
}
event.preventDefault();
Popup.back();
},
});
@@ -0,0 +1,93 @@
template(name="adminFeatures")
.setting-content
unless currentUser.isAdmin
| {{_ 'error-notAuthorized'}}
else
.content-title.ext-box
span
span.emoji-icon
i.fa.fa-flask
| {{_ 'features'}}
.content-body
.side-menu
ul
li(class="{{#if isPerformanceActive}}active{{/if}}")
a.js-features-menu(data-id="performance")
span.emoji-icon
i.fa.fa-tachometer
| {{_ 'features-performance'}}
li(class="{{#if isSecurityActive}}active{{/if}}")
a.js-features-menu(data-id="security")
span.emoji-icon
i.fa.fa-shield
| {{_ 'features-security'}}
li(class="{{#if isNotificationsActive}}active{{/if}}")
a.js-features-menu(data-id="notifications")
span.emoji-icon
i.fa.fa-bell
| {{_ 'features-notifications'}}
.main-body
if isPerformanceActive
ul.setting-detail
li.layout-form
.title {{_ 'cards-loading'}}
p.description {{_ 'cards-loading-description'}}
if isSecurityActive
ul.setting-detail
li
a.flex.js-toggle-render-links-as-plain-text
.materialCheckBox(class="{{#if renderLinksAsPlainText}}is-checked{{/if}}")
span {{_ 'render-links-as-plain-text'}}
p.description {{_ 'render-links-as-plain-text-description'}}
li
a.flex.js-toggle-always-show-code-as-text
.materialCheckBox(class="{{#if alwaysShowCodeAsText}}is-checked{{/if}}")
span {{_ 'always-show-code-as-text'}}
p.description {{_ 'always-show-code-as-text-description'}}
li
a.flex.js-toggle-disable-all-import
.materialCheckBox(class="{{#if disableAllImport}}is-checked{{/if}}")
span {{_ 'disable-all-import'}}
p.description {{_ 'disable-all-import-description'}}
li
a.flex.js-toggle-disable-all-export
.materialCheckBox(class="{{#if disableAllExport}}is-checked{{/if}}")
span {{_ 'disable-all-export'}}
p.description {{_ 'disable-all-export-description'}}
li
a.flex.js-toggle-disable-import-avatars
.materialCheckBox(class="{{#if disableImportAvatars}}is-checked{{/if}}")
span {{_ 'disable-import-avatars'}}
p.description {{_ 'disable-import-avatars-description'}}
li
a.flex.js-toggle-disable-export-avatars
.materialCheckBox(class="{{#if disableExportAvatars}}is-checked{{/if}}")
span {{_ 'disable-export-avatars'}}
p.description {{_ 'disable-export-avatars-description'}}
li
a.flex.js-toggle-anonymize-import-users
.materialCheckBox(class="{{#if anonymizeImportUsers}}is-checked{{/if}}")
span {{_ 'anonymize-import-users'}}
p.description {{_ 'anonymize-import-users-description'}}
li
a.flex.js-toggle-anonymize-export-users
.materialCheckBox(class="{{#if anonymizeExportUsers}}is-checked{{/if}}")
span {{_ 'anonymize-export-users'}}
p.description {{_ 'anonymize-export-users-description'}}
if isNotificationsActive
ul.setting-detail
li
a.flex.js-toggle-disable-activities
.materialCheckBox(class="{{#if disableActivities}}is-checked{{/if}}")
span {{_ 'disable-activities'}}
p.description {{_ 'disable-activities-description'}}
li
a.flex.js-toggle-disable-notifications
.materialCheckBox(class="{{#if disableNotifications}}is-checked{{/if}}")
span {{_ 'disable-notifications'}}
p.description {{_ 'disable-notifications-description'}}
li
a.flex.js-toggle-disable-watch
.materialCheckBox(class="{{#if disableWatch}}is-checked{{/if}}")
span {{_ 'disable-watch'}}
p.description {{_ 'disable-watch-description'}}
+104
View File
@@ -0,0 +1,104 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import Settings from '/models/settings';
// Admin Panel / Features — a home for optional / performance / tier-gated
// capabilities. First entry: "Card loading" (all | lazy), which also mirrors the
// CARDS_LOADING env var. Grouped by category in the side menu so more feature
// toggles (and, later, pricing-tier gating) can be added here.
Template.adminFeatures.onCreated(function () {
this.activePane = new ReactiveVar('performance');
Meteor.subscribe('setting');
});
Template.adminFeatures.helpers({
isPerformanceActive() {
return Template.instance().activePane.get() === 'performance';
},
isSecurityActive() {
return Template.instance().activePane.get() === 'security';
},
isNotificationsActive() {
return Template.instance().activePane.get() === 'notifications';
},
renderLinksAsPlainText() {
return (ReactiveCache.getCurrentSetting() || {}).renderLinksAsPlainText;
},
alwaysShowCodeAsText() {
return (ReactiveCache.getCurrentSetting() || {}).alwaysShowCodeAsText;
},
disableAllImport() {
return (ReactiveCache.getCurrentSetting() || {}).disableAllImport;
},
disableAllExport() {
return (ReactiveCache.getCurrentSetting() || {}).disableAllExport;
},
disableImportAvatars() {
return (ReactiveCache.getCurrentSetting() || {}).disableImportAvatars;
},
disableExportAvatars() {
return (ReactiveCache.getCurrentSetting() || {}).disableExportAvatars;
},
anonymizeImportUsers() {
return (ReactiveCache.getCurrentSetting() || {}).anonymizeImportUsers;
},
anonymizeExportUsers() {
return (ReactiveCache.getCurrentSetting() || {}).anonymizeExportUsers;
},
disableActivities() {
return (ReactiveCache.getCurrentSetting() || {}).disableActivities;
},
disableNotifications() {
return (ReactiveCache.getCurrentSetting() || {}).disableNotifications;
},
disableWatch() {
return (ReactiveCache.getCurrentSetting() || {}).disableWatch;
},
});
// Toggle one boolean setting field, saving immediately.
function toggleSettingField(field) {
const setting = ReactiveCache.getCurrentSetting();
if (setting) {
Settings.update(setting._id, { $set: { [field]: !setting[field] } });
}
}
Template.adminFeatures.events({
'click .js-features-menu'(event, tpl) {
tpl.activePane.set(event.currentTarget.dataset.id);
},
'click .js-toggle-render-links-as-plain-text'() {
toggleSettingField('renderLinksAsPlainText');
},
'click .js-toggle-always-show-code-as-text'() {
toggleSettingField('alwaysShowCodeAsText');
},
'click .js-toggle-disable-all-import'() {
toggleSettingField('disableAllImport');
},
'click .js-toggle-disable-all-export'() {
toggleSettingField('disableAllExport');
},
'click .js-toggle-disable-import-avatars'() {
toggleSettingField('disableImportAvatars');
},
'click .js-toggle-disable-export-avatars'() {
toggleSettingField('disableExportAvatars');
},
'click .js-toggle-anonymize-import-users'() {
toggleSettingField('anonymizeImportUsers');
},
'click .js-toggle-anonymize-export-users'() {
toggleSettingField('anonymizeExportUsers');
},
'click .js-toggle-disable-activities'() {
toggleSettingField('disableActivities');
},
'click .js-toggle-disable-notifications'() {
toggleSettingField('disableNotifications');
},
'click .js-toggle-disable-watch'() {
toggleSettingField('disableWatch');
},
});
+60 -29
View File
@@ -1,60 +1,51 @@
/* Search + pagination controls for the admin report tabs (files, rules,
boards, cards). Styled to match the admin People panel (peopleBody.css). */
.admin-report-search {
/* One controls row per report: the search field on the left, the pagination
controls pushed to the right (margin-inline-start:auto mirrors automatically in
RTL). No separate Search button — typing + Enter searches. The controls use the
theme accent (Member Settings → Change color sets --theme-accent on :root),
falling back to WeKan blue when no custom theme is chosen. */
.admin-report-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
height: 34px;
margin-bottom: 12px;
}
.admin-report-search input {
flex: 1;
.admin-report-controls input {
flex: 0 1 320px;
max-width: 320px;
padding: 0 8px;
height: 34px;
}
.admin-report-search button {
min-width: 90px;
height: 34px;
background: #01628c;
color: #fff;
border: none;
border-radius: 0.4vw;
cursor: pointer;
padding: 0 12px;
}
.admin-report-search button:hover {
background: #005377;
}
.admin-report-search button:active {
background: #004766;
}
.admin-report-pagination {
margin-inline-start: auto; /* push pagination to the end of the controls row */
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.admin-report-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.admin-report-pagination button:hover:not(.disabled) {
background: var(--theme-accent, #01628c);
color: #fff;
}
.admin-report-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -66,5 +57,45 @@
.admin-report-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
/* Problems page: menu separator between the event streams (Summary/Security/
Speed/Tests) and the moved report items, and the read-only event table. */
.admin-reports-content .side-menu .admin-reports-separator hr {
margin: 6px 0;
border: 0;
border-top: 1px solid rgba(0,0,0,0.15);
}
.admin-event-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.admin-event-controls input {
flex: 0 1 320px;
max-width: 320px;
height: 34px;
padding: 0 8px;
}
.admin-event-controls .admin-event-pagination {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 8px;
}
.admin-event-controls .admin-event-pagination a {
cursor: pointer;
color: var(--theme-accent, #01628c);
font-weight: bold;
padding: 0 6px;
}
.admin-event-controls .admin-event-pagination a.disabled { opacity: 0.4; pointer-events: none; }
table.admin-event-table { width: 100%; border-collapse: collapse; }
table.admin-event-table th, table.admin-event-table td {
text-align: start; padding: 4px 8px; border-bottom: 1px solid rgba(0,0,0,0.08); vertical-align: top;
}
table.admin-event-table .admin-event-at { white-space: nowrap; }
table.admin-event-table .admin-event-sev[data-sev="high"],
table.admin-event-table .admin-event-sev[data-sev="critical"] { color: #c0392b; font-weight: bold; }
table.admin-event-table .admin-event-detail { word-break: break-word; }
+149 -45
View File
@@ -6,6 +6,34 @@ template(name="adminReports")
.content-body
.side-menu
ul
li
a.js-report-summary(data-id="report-summary")
i.fa.fa-list
| {{_ 'summary'}}
li
a.js-report-security(data-id="report-security")
i.fa.fa-shield
| {{_ 'securityReportTitle'}}
li
a.js-report-speed(data-id="report-speed")
i.fa.fa-tachometer
| {{_ 'speedReportTitle'}}
li
a.js-report-tests(data-id="report-tests")
i.fa.fa-flask
| {{_ 'testsReportTitle'}}
li
a.js-report-cpu(data-id="report-cpu")
i.fa.fa-tachometer
| {{_ 'cpuReportTitle'}}
li.admin-reports-separator
hr
li
a.js-report-broken(data-id="report-broken")
i.fa.fa-chain-broken
@@ -31,9 +59,24 @@ template(name="adminReports")
i.fa.fa-id-card-o
| {{_ 'cardsReportTitle'}}
li
a.js-report-impersonation(data-id="report-impersonation")
i.fa.fa-user-secret
| {{_ 'impersonationReportTitle'}}
.main-body
if loading.get
+spinner
else if showSummary.get
+problemsSummary
else if showSecurity.get
+eventStreamReport(stream="security")
else if showSpeed.get
+eventStreamReport(stream="speed")
else if showTests.get
+eventStreamReport(stream="tests")
else if showCpu.get
+eventStreamReport(stream="cpu")
else if showBrokenCardsReport.get
+brokenCardsReport
else if showFilesReport.get
@@ -44,6 +87,8 @@ template(name="adminReports")
+boardsReport(currentPage=boardsCurrentPage totalPages=boardsTotalPages hasPrevPage=hasBoardsPrevPage hasNextPage=hasBoardsNextPage)
else if showCardsReport.get
+cardsReport(currentPage=cardsCurrentPage totalPages=cardsTotalPages hasPrevPage=hasCardsPrevPage hasNextPage=hasCardsNextPage)
else if showImpersonationReport.get
+impersonationReport(currentPage=impersonationCurrentPage totalPages=impersonationTotalPages hasPrevPage=hasImpersonationPrevPage hasNextPage=hasImpersonationNextPage)
template(name="brokenCardsReport")
@@ -56,17 +101,14 @@ template(name="brokenCardsReport")
template(name="rulesReport")
h1 {{_ 'rulesReportTitle'}}
.admin-report-search
input.js-rules-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-rules-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-rules-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table
thead
@@ -88,17 +130,14 @@ template(name="rulesReport")
template(name="filesReport")
h1 {{_ 'filesReportTitle'}}
.admin-report-search
input.js-files-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-files-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-files-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table
thead
@@ -113,7 +152,7 @@ template(name="filesReport")
each att in results
tbody
tr
td {{ att.name }}
td {{ cleanFilename att.name }}
td.right {{ fileSize att.size }}
td {{ att.type }}
td {{ att._id }}
@@ -124,17 +163,14 @@ template(name="filesReport")
template(name="cardsReport")
h1 {{_ 'cardsReportTitle'}}
.admin-report-search
input.js-cards-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-cards-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-cards-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
@@ -158,19 +194,50 @@ template(name="cardsReport")
else
div {{_ 'no-results' }}
template(name="impersonationReport")
h1 {{_ 'impersonationReportTitle'}}
.admin-report-controls
input.js-impersonation-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-impersonation-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-impersonation-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
tr
th {{_ 'date'}}
th {{_ 'impersonation-admin'}}
th {{_ 'impersonation-user'}}
th {{_ 'board'}}
th {{_ 'reason'}}
each row in results
tbody
tr
td {{ formatDate row.createdAt }}
td
if row.adminId
a.edit-user.js-impersonation-edit-user(data-user-id="{{ row.adminId }}") {{ userName row.adminId }}
td
if row.userId
a.edit-user.js-impersonation-edit-user(data-user-id="{{ row.userId }}") {{ userName row.userId }}
td {{ row.boardId }}
td {{ row.reason }}
else
div {{_ 'no-results' }}
template(name="boardsReport")
h1 {{_ 'boardsReportTitle'}}
.admin-report-search
input.js-boards-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-boards-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-boards-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
@@ -195,3 +262,40 @@ template(name="boardsReport")
td {{teams board.teams }}
else
div {{_ 'no-results' }}
template(name="eventStreamReport")
.global-search-results-list-wrapper
h1 {{streamTitle}}
.admin-event-controls
input.js-event-search(type="search" placeholder="{{_ 'search'}}" value="{{searchTerm}}")
span.admin-event-pagination
a.js-event-prev(class="{{prevDisabled}}") ‹
span.admin-event-page {{currentPage}} / {{totalPages}}
a.js-event-next(class="{{nextDisabled}}") ›
if rowCount
table.admin-event-table
thead
tr
th {{_ 'event-datetime'}}
th {{_ 'event-category'}}
th {{_ 'event-bleed'}}
th {{_ 'event-severity'}}
th {{_ 'event-action'}}
th {{_ 'event-source'}}
th {{_ 'username'}}
th {{_ 'event-detail'}}
tbody
each row in rows
tr
td.admin-event-at {{formatAt row.at}}
td {{row.category}}
td {{row.bleed}}
td.admin-event-sev(data-sev="{{row.severity}}") {{row.severity}}
td {{row.action}}
td {{row.source}}
td
if row.userId
a.edit-user.js-event-edit-user(data-user-id="{{ row.userId }}") {{ userName row.userId }}
td.admin-event-detail {{row.detail}}
else
p {{_ 'no-new-problems'}}
+235 -10
View File
@@ -6,6 +6,7 @@ import SessionData from '/models/usersessiondata';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Rules from '/models/rules';
import ImpersonatedUsers from '/models/impersonatedUsers';
const { filesize } = require('filesize');
// --- Shared helper functions (formerly AdminReport base class methods) ---
@@ -60,17 +61,25 @@ function reportConfig(tmpl) {
'report-rules': { page: tmpl.rulesPage, count: tmpl.rulesCount, search: tmpl.rulesSearch, pub: 'rulesReport', countMethod: 'getRulesReportCount' },
'report-boards': { page: tmpl.boardsPage, count: tmpl.boardsCount, search: tmpl.boardsSearch, pub: 'boardsReport', countMethod: 'getBoardsReportCount' },
'report-cards': { page: tmpl.cardsPage, count: tmpl.cardsCount, search: tmpl.cardsSearch, pub: 'cardsReport', countMethod: 'getCardsReportCount' },
'report-impersonation': { page: tmpl.impersonationPage, count: tmpl.impersonationCount, search: tmpl.impersonationSearch, pub: 'impersonationReport', countMethod: 'getImpersonationReportCount' },
};
}
Template.adminReports.onCreated(function () {
this.subscription = null;
// Problems page opens on the Summary tab (the acknowledge checkbox list).
this.showSummary = new ReactiveVar(true);
this.showSecurity = new ReactiveVar(false);
this.showSpeed = new ReactiveVar(false);
this.showTests = new ReactiveVar(false);
this.showCpu = new ReactiveVar(false);
this.showFilesReport = new ReactiveVar(false);
this.showBrokenCardsReport = new ReactiveVar(false);
this.showOrphanedFilesReport = new ReactiveVar(false);
this.showRulesReport = new ReactiveVar(false);
this.showCardsReport = new ReactiveVar(false);
this.showBoardsReport = new ReactiveVar(false);
this.showImpersonationReport = new ReactiveVar(false);
this.sessionId = SessionData.getSessionId();
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
@@ -81,15 +90,18 @@ Template.adminReports.onCreated(function () {
this.rulesPage = new ReactiveVar(1);
this.boardsPage = new ReactiveVar(1);
this.cardsPage = new ReactiveVar(1);
this.impersonationPage = new ReactiveVar(1);
this.filesCount = new ReactiveVar(0);
this.rulesCount = new ReactiveVar(0);
this.boardsCount = new ReactiveVar(0);
this.cardsCount = new ReactiveVar(0);
this.impersonationCount = new ReactiveVar(0);
// Current search term per report (empty string = no filter).
this.filesSearch = new ReactiveVar('');
this.rulesSearch = new ReactiveVar('');
this.boardsSearch = new ReactiveVar('');
this.cardsSearch = new ReactiveVar('');
this.impersonationSearch = new ReactiveVar('');
// (Re)subscribe the given report for its current page and refresh its total
// count. Server-side search + limit/skip means only the matching page ever
@@ -109,8 +121,21 @@ Template.adminReports.onCreated(function () {
const searchTerm = cfg.search.get();
const limit = REPORTS_PER_PAGE;
const skip = (cfg.page.get() - 1) * REPORTS_PER_PAGE;
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, () => {
this.loading.set(false);
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, {
onReady: () => {
this.loading.set(false);
},
// A publication that errors (or is stopped) must never leave the report
// stuck on the loading spinner forever: clear the spinner and, on a real
// error, surface it. Without this an admin only ever saw an endless spinner
// whenever a report subscription failed on the server.
onStop: (error) => {
if (error) {
console.error(`Report subscription '${cfg.pub}' failed:`, error);
this.error.set(error.reason || error.message || String(error));
}
this.loading.set(false);
},
});
Meteor.call(cfg.countMethod, searchTerm, (error, count) => {
if (error) {
@@ -131,6 +156,21 @@ Template.adminReports.onCreated(function () {
});
Template.adminReports.helpers({
showSummary() {
return Template.instance().showSummary;
},
showSecurity() {
return Template.instance().showSecurity;
},
showSpeed() {
return Template.instance().showSpeed;
},
showTests() {
return Template.instance().showTests;
},
showCpu() {
return Template.instance().showCpu;
},
showFilesReport() {
return Template.instance().showFilesReport;
},
@@ -149,6 +189,9 @@ Template.adminReports.helpers({
showBoardsReport() {
return Template.instance().showBoardsReport;
},
showImpersonationReport() {
return Template.instance().showImpersonationReport;
},
loading() {
return Template.instance().loading;
},
@@ -185,9 +228,32 @@ Template.adminReports.helpers({
const tpl = Template.instance();
return tpl.cardsPage.get() < Math.max(1, Math.ceil((tpl.cardsCount.get() || 0) / REPORTS_PER_PAGE));
},
impersonationCurrentPage() { return Template.instance().impersonationPage.get(); },
impersonationTotalPages() { return Math.max(1, Math.ceil((Template.instance().impersonationCount.get() || 0) / REPORTS_PER_PAGE)); },
hasImpersonationPrevPage() { return Template.instance().impersonationPage.get() > 1; },
hasImpersonationNextPage() {
const tpl = Template.instance();
return tpl.impersonationPage.get() < Math.max(1, Math.ceil((tpl.impersonationCount.get() || 0) / REPORTS_PER_PAGE));
},
});
Template.adminReports.events({
'click a.js-report-summary'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-security'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-speed'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-tests'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-cpu'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-broken'(event) {
switchMenu(event, Template.instance());
},
@@ -203,6 +269,9 @@ Template.adminReports.events({
'click a.js-report-boards'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-impersonation'(event) {
switchMenu(event, Template.instance());
},
// --- Pagination controls (buttons live inside the report sub-templates,
// their clicks bubble up to this parent template) ---
@@ -214,16 +283,15 @@ Template.adminReports.events({
'click .js-boards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-boards'); },
'click .js-cards-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-cards'); },
'click .js-cards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-cards'); },
'click .js-impersonation-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-impersonation'); },
'click .js-impersonation-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-impersonation'); },
// --- Search (one input + button per report, mirrors the People panel) ---
'click .js-files-search-button'(event, tmpl) { runSearch(tmpl, 'report-files', '.js-files-search-input'); },
'keydown .js-files-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-files', '.js-files-search-input'); },
'click .js-rules-search-button'(event, tmpl) { runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
'keydown .js-rules-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
'click .js-boards-search-button'(event, tmpl) { runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
'keydown .js-boards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
'click .js-cards-search-button'(event, tmpl) { runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
'keydown .js-cards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
'keydown .js-impersonation-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
});
function goPrevPage(event, tmpl, reportId) {
@@ -261,12 +329,18 @@ function switchMenu(event, tmpl) {
const target = $(event.target);
if (!target.hasClass('active')) {
tmpl.loading.set(true);
tmpl.showSummary.set(false);
tmpl.showSecurity.set(false);
tmpl.showSpeed.set(false);
tmpl.showTests.set(false);
tmpl.showCpu.set(false);
tmpl.showFilesReport.set(false);
tmpl.showBrokenCardsReport.set(false);
tmpl.showOrphanedFilesReport.set(false);
tmpl.showRulesReport.set(false);
tmpl.showBoardsReport.set(false);
tmpl.showCardsReport.set(false);
tmpl.showImpersonationReport.set(false);
if (tmpl.subscription) {
tmpl.subscription.stop();
}
@@ -276,7 +350,24 @@ function switchMenu(event, tmpl) {
const targetID = target.data('id');
tmpl.activeReport.set(targetID);
if ('report-broken' === targetID) {
// Summary + the Security/Speed/Tests streams load their own data (via methods,
// not a subscription), so just show them and clear the spinner.
if ('report-summary' === targetID) {
tmpl.showSummary.set(true);
tmpl.loading.set(false);
} else if ('report-security' === targetID) {
tmpl.showSecurity.set(true);
tmpl.loading.set(false);
} else if ('report-speed' === targetID) {
tmpl.showSpeed.set(true);
tmpl.loading.set(false);
} else if ('report-tests' === targetID) {
tmpl.showTests.set(true);
tmpl.loading.set(false);
} else if ('report-cpu' === targetID) {
tmpl.showCpu.set(true);
tmpl.loading.set(false);
} else if ('report-broken' === targetID) {
tmpl.showBrokenCardsReport.set(true);
tmpl.subscription = Meteor.subscribe(
'brokenCards',
@@ -305,6 +396,11 @@ function switchMenu(event, tmpl) {
tmpl.cardsPage.set(1);
tmpl.cardsSearch.set('');
tmpl.loadReport('report-cards');
} else if ('report-impersonation' === targetID) {
tmpl.showImpersonationReport.set(true);
tmpl.impersonationPage.set(1);
tmpl.impersonationSearch.set('');
tmpl.loadReport('report-impersonation');
}
}
}
@@ -313,10 +409,31 @@ function switchMenu(event, tmpl) {
Template.filesReport.helpers({
results() {
return collectionResults(Attachments, { name: 1 });
// The filename cell renders via the global {{cleanFilename}} helper: the name
// is URL-decoded, homoglyphs are folded, and invisible / exploit characters
// are removed, so it is always shown as a plain, readable name.
//
// Prefer the UNDERLYING reactive minimongo collection (Attachments.collection):
// the 'attachmentsList' publication delivers the page via this.added, and a
// plain Mongo.Collection cursor reacts to those adds and yields plain docs,
// whereas the ostrio FilesCursor (Attachments.find()) does not reliably re-run
// in a Blaze helper. Fall back to the wrapper if .collection is unavailable and
// NEVER throw: a throwing helper would abort the whole filesReport render
// (blank pane instead of the report + its "no results" state).
const coll = (Attachments && Attachments.collection) || Attachments;
try {
return collectionResults(coll, { name: 1 });
} catch (e) {
return [];
}
},
resultsCount() {
return collectionResultsCount(Attachments);
const coll = (Attachments && Attachments.collection) || Attachments;
try {
return collectionResultsCount(coll);
} catch (e) {
return 0;
}
},
fileSize(size) {
return fileSizeHelper(size);
@@ -399,7 +516,8 @@ Template.boardsReport.helpers({
Template.cardsReport.helpers({
results() {
return collectionResults(Cards, { boardId: 1, sort: 1 });
// Match the server publication's index-backed sort (see cards.js).
return collectionResults(Cards, { boardId: 1, createdAt: -1 });
},
resultsCount() {
return collectionResultsCount(Cards);
@@ -418,6 +536,36 @@ Template.cardsReport.helpers({
},
});
// --- impersonationReport template ---
Template.impersonationReport.helpers({
results() {
// The publication already paginates + sorts newest-first; mirror that sort.
return collectionResults(ImpersonatedUsers, { createdAt: -1 });
},
resultsCount() {
return collectionResultsCount(ImpersonatedUsers);
},
userName(userId) {
if (!userId) return '';
return ReactiveCache.getUser(userId)?.username || userId;
},
formatDate(date) {
return date ? new Date(date).toLocaleString() : '';
},
});
Template.impersonationReport.events({
// Clicking a username opens the same "Edit user" popup as Admin Panel / People.
'click .js-impersonation-edit-user'(event) {
event.preventDefault();
const userId = event.currentTarget.getAttribute('data-user-id');
if (userId) {
Popup.open('editUser').call({ userId }, event);
}
},
});
// --- brokenCardsReport template ---
Template.brokenCardsReport.onCreated(function () {
@@ -456,3 +604,80 @@ Template.brokenCardsReport.events({
tpl.search.previousPage();
},
});
// --- Read-only event stream report (Security / Speed / Tests) ---
// Reads the eventlog collection through the admin-only eventLogPage/eventLogCount
// methods and shows a paginated, searchable, READ-ONLY table (no acknowledge —
// that lives only on the Summary page). See docs/Security/Remediation/WeKan.md.
const EVENTS_PER_PAGE = 25;
function eventStreamTitleKey(stream) {
return { security: 'securityReportTitle', speed: 'speedReportTitle', tests: 'testsReportTitle', cpu: 'cpuReportTitle' }[stream] || 'summary';
}
Template.eventStreamReport.onCreated(function () {
this.stream = this.data.stream;
this.page = new ReactiveVar(1);
this.total = new ReactiveVar(0);
this.rows = new ReactiveVar([]);
this.search = new ReactiveVar('');
this.load = () => {
const stream = this.stream;
const search = this.search.get();
const page = this.page.get();
Meteor.call('eventLogCount', stream, search, (err, count) => {
if (!err) this.total.set(count || 0);
});
Meteor.call('eventLogPage', stream, EVENTS_PER_PAGE, (page - 1) * EVENTS_PER_PAGE, search, (err, rows) => {
if (!err) this.rows.set(Array.isArray(rows) ? rows : []);
});
};
this.load();
});
Template.eventStreamReport.helpers({
rows() { return Template.instance().rows.get(); },
rowCount() { return (Template.instance().rows.get() || []).length; },
currentPage() { return Template.instance().page.get(); },
totalPages() { return Math.max(1, Math.ceil(Template.instance().total.get() / EVENTS_PER_PAGE)); },
searchTerm() { return Template.instance().search.get(); },
streamTitle() { return TAPi18n.__(eventStreamTitleKey(Template.instance().stream)); },
prevDisabled() { return Template.instance().page.get() <= 1 ? 'disabled' : ''; },
nextDisabled() {
const t = Template.instance();
return t.page.get() >= Math.max(1, Math.ceil(t.total.get() / EVENTS_PER_PAGE)) ? 'disabled' : '';
},
formatAt(at) {
if (!at) return '';
try { return new Date(at).toISOString().replace('T', ' ').slice(0, 19); } catch (e) { return String(at); }
},
// The user who triggered the event (e.g. who uploaded a sanitized file). Empty
// for events with no associated user.
userName(userId) {
if (!userId) return '';
return ReactiveCache.getUser(userId)?.username || userId;
},
});
Template.eventStreamReport.events({
// Clicking the uploader opens the same "Edit user" popup as Admin Panel / People.
'click .js-event-edit-user'(event) {
event.preventDefault();
const userId = event.currentTarget.getAttribute('data-user-id');
if (userId) {
Popup.open('editUser').call({ userId }, event);
}
},
'input .js-event-search'(event, tmpl) {
tmpl.search.set(event.currentTarget.value.trim());
tmpl.page.set(1);
tmpl.load();
},
'click .js-event-prev'(event, tmpl) {
if (tmpl.page.get() > 1) { tmpl.page.set(tmpl.page.get() - 1); tmpl.load(); }
},
'click .js-event-next'(event, tmpl) {
const totalPages = Math.max(1, Math.ceil(tmpl.total.get() / EVENTS_PER_PAGE));
if (tmpl.page.get() < totalPages) { tmpl.page.set(tmpl.page.get() + 1); tmpl.load(); }
},
});
+154
View File
@@ -51,6 +51,22 @@ template(name="attachments")
span.emoji-icon
i.fa.fa-cloud
| {{_ 'gcs-storage'}}
li(class="{{#if isDatabaseMigrationActive}}active{{/if}}")
a.js-attachments-menu(data-id="database-migration")
span.emoji-icon
i.fa.fa-exchange
| {{_ 'database-migration'}}
li(class="{{#if isBackupActive}}active{{/if}}")
a.js-attachments-menu(data-id="backup")
span.emoji-icon
i.fa.fa-archive
| {{_ 'backup'}}
if isSandstorm
li(class="{{#if isSandstormActive}}active{{/if}}")
a.js-attachments-menu(data-id="sandstorm")
span.emoji-icon
i.fa.fa-hdd-o
| Sandstorm
.main-body
if loading.get
@@ -475,6 +491,144 @@ template(name="attachments")
if cloudTestError.gcs
p.form-text.text-danger {{_ 'cloud-connection-failed'}}: {{cloudTestError.gcs}}
else if isDatabaseMigrationActive
ul#attachment-database-migration-setting.setting-detail
li
h3 {{_ 'database-migration'}}
.description {{_ 'database-migration-description'}}
.form-group
button.js-migrate-to-ferretdb.btn.btn-primary(type="button" disabled=migrateRunning) {{_ 'database-migrate-to-ferretdb'}}
button.js-migrate-to-mongodb.btn.btn-primary(type="button" disabled=migrateRunning) {{_ 'database-migrate-to-mongodb'}}
if migrateStatus
.move-progress-section
.move-progress-counter
| {{_ 'database-migration-phase'}}: {{migrateStatus.phase}}
if migrateStatus.collection
| — {{migrateStatus.collection}} ({{migrateStatus.collDone}}/{{migrateStatus.collTotal}}), {{migrateStatus.collectionsDone}}/{{migrateStatus.collectionsTotal}}
if migrateStatus.error
p.form-text.text-danger {{migrateStatus.error}}
if migrateSuccess
p.form-text.text-success {{_ 'database-migration-done'}}
else if isBackupActive
ul#attachment-backup-setting.setting-detail
li
h3 {{_ 'backup'}}
.description {{_ 'backup-description'}}
.form-group
label
input.js-backup-attachments(type="checkbox" checked="checked")
| {{_ 'attachments'}}
label
input.js-backup-avatars(type="checkbox" checked="checked")
| {{_ 'avatars'}}
label
input.js-backup-data(type="checkbox" checked="checked")
| {{_ 'backup-data'}}
.form-group
label {{_ 'backup-storage'}}
select.wekan-form-control.js-backup-storage
option(value="filesystem") {{_ 'filesystem-storage'}}
option(value="s3") {{_ 's3-minio-storage'}}
option(value="azure") {{_ 'azure-blob-storage'}}
option(value="gcs") {{_ 'gcs-storage'}}
.form-group
button.js-run-backup.btn.btn-primary(type="button" disabled=backupRunning) {{_ 'backup-now'}}
if backupStatus
p.form-text {{_ 'database-migration-phase'}}: {{backupStatus.phase}} {{backupStatus.detail}}
if backupStatus.error
p.form-text.text-danger {{backupStatus.error}}
if backupSuccess
p.form-text.text-success {{_ 'backup-done'}}: {{backupStatus.file}}
h4 {{_ 'backup-schedule'}}
.form-group
label {{_ 'backup-frequency'}}
select.wekan-form-control.js-backup-frequency
option(value="off" selected=scheduleOff) {{_ 'backup-frequency-off'}}
option(value="daily" selected=scheduleDaily) {{_ 'backup-frequency-daily'}}
option(value="weekly" selected=scheduleWeekly) {{_ 'backup-frequency-weekly'}}
option(value="monthly" selected=scheduleMonthly) {{_ 'backup-frequency-monthly'}}
.form-group
label {{_ 'backup-time'}}
input.wekan-form-control.js-backup-time(type="time" value=scheduleTime)
.form-group
label {{_ 'backup-day-of-week'}}
input.wekan-form-control.js-backup-dow(type="text" value=scheduleDow placeholder="Sunday")
.form-group
label {{_ 'backup-day-of-month'}}
input.wekan-form-control.js-backup-dom(type="number" min="1" max="28" value=scheduleDom)
.form-group
button.js-save-backup-schedule.btn.btn-primary(type="button") {{_ 'save'}}
h4 {{_ 'backup-restore'}}
.form-group
button.js-list-backups.btn.btn-secondary(type="button") {{_ 'backup-list'}}
if backupList
table.gridfs-stats-table
tr
th
th {{_ 'backup-storage'}}
th {{_ 'backup-datetime'}}
th {{_ 'backup-path'}}
each backupList
tr
td
input.js-backup-select(type="radio" name="backup-select" value=path)
td {{storage}}
td {{datetime}}
td {{path}}
.form-group
label {{_ 'backup-restore-mode'}}
select.wekan-form-control.js-restore-mode
option(value="add-missing") {{_ 'backup-restore-add-missing'}}
option(value="replace-all") {{_ 'backup-restore-replace-all'}}
.form-group
button.js-restore-backup.btn.btn-warning(type="button" disabled=backupRunning) {{_ 'backup-restore'}}
else if isSandstormActive
ul#attachment-sandstorm-setting.setting-detail
li
h3 Sandstorm
.description {{_ 'sandstorm-migration-description'}}
if sandstormStatus
.form-group
strong {{_ 'sandstorm-migration-status'}}
if sandstormStatus.migrationSuccess
span.text-success {{_ 'sandstorm-migration-success'}}
else if sandstormStatus.migrationDone
span.text-danger {{_ 'sandstorm-migration-failed'}}
else
span.text-muted {{_ 'sandstorm-migration-pending'}}
if sandstormStatus.status
p.form-text.text-muted {{_ 'database-migration-phase'}}: {{sandstormStatus.status.phase}}
table.gridfs-stats-table
tr
th {{_ 'sandstorm-storage-item'}}
th {{_ 'sandstorm-disk-usage'}}
tr
td {{_ 'sandstorm-raw-mongodb'}}
td {{rawMongoSize}}
tr
td FerretDB (SQLite)
td {{ferretSize}}
tr
td {{_ 'attachments'}}
td {{attachmentsSize}}
tr
td {{_ 'avatars'}}
td {{avatarsSize}}
if sandstormStatus.rawMongoExists
.form-group
.description {{_ 'sandstorm-delete-raw-mongodb-description'}}
button.js-sandstorm-delete-raw-mongodb.btn.btn-warning(type="button" disabled=sandstormDeleteDisabled) {{_ 'sandstorm-delete-raw-mongodb'}}
if sandstormDeleteResult
p.form-text.text-success {{_ 'sandstorm-raw-mongodb-deleted'}} ({{sandstormDeleteFreed}})
if sandstormDeleteError
p.form-text.text-danger {{sandstormDeleteError}}
else
p.form-text.text-muted {{_ 'loading'}}
template(name="moveAttachments")
.move-storage-form
.move-storage-selects
+249 -5
View File
@@ -234,6 +234,15 @@ Template.attachments.onCreated(function () {
this.activeSection = new ReactiveVar('move');
this.storageSettingsSubscription = Meteor.subscribe('attachmentStorageSettings');
this.attachmentStorageSettings = new ReactiveVar(null);
// #6473: the real storage paths only exist on the SERVER (WRITABLE_PATH is a
// server environment variable — process.env in the browser never has it, so
// computing the path client-side always showed the misleading "/data").
this.storagePaths = new ReactiveVar(null);
Meteor.call('getAttachmentStoragePaths', (err, paths) => {
if (!err && paths) {
this.storagePaths.set(paths);
}
});
this.attachmentLimitUnits = new ReactiveVar({
attachmentsUploadMaxBytes: 'mb',
attachmentsDownloadMaxBytes: 'mb',
@@ -267,6 +276,28 @@ Template.attachments.onCreated(function () {
this.cloudTestResults = new ReactiveVar({});
this.cloudTestErrors = new ReactiveVar({});
this.loading = new ReactiveVar(false);
this.migrateStatus = new ReactiveVar(null);
this.migratePoll = null;
this.backupStatus = new ReactiveVar(null);
this.backupList = new ReactiveVar(null);
this.backupSchedule = new ReactiveVar(null);
this.selectedBackup = new ReactiveVar('');
this.backupPoll = null;
Meteor.call('getBackupSchedule', (err, s) => { if (!err && s) this.backupSchedule.set(s); });
// Sandstorm: MongoDB 3 -> FerretDB migration status + raw-MongoDB disk usage.
this.sandstormStatus = new ReactiveVar(null);
this.sandstormDeleteResult = new ReactiveVar(null);
this.sandstormDeleteError = new ReactiveVar('');
// Fetch (once) when the Sandstorm tab is opened. Migration runs in start.js
// before the app boots, so the status is static by the time the UI is shown.
this.autorun(() => {
if (this.activeSection.get() === 'sandstorm') {
Meteor.call('sandstormMigrationStatus', (err, res) => {
if (!err && res) this.sandstormStatus.set(res);
});
}
});
this.autorun(() => {
const ready = this.storageSettingsSubscription.ready();
@@ -279,6 +310,54 @@ Template.attachments.onCreated(function () {
});
});
Template.attachments.onDestroyed(function () {
if (this.migratePoll) {
Meteor.clearTimeout(this.migratePoll);
this.migratePoll = null;
}
if (this.backupPoll) {
Meteor.clearTimeout(this.backupPoll);
this.backupPoll = null;
}
});
// Text-data database migration (MongoDB <-> FerretDB v1 SQLite) helpers.
function pollMigrateStatus(tpl) {
Meteor.call('migrateTextDatabaseStatus', (err, status) => {
if (err || !status) return;
tpl.migrateStatus.set(status);
if (status.running) {
tpl.migratePoll = Meteor.setTimeout(() => pollMigrateStatus(tpl), 2000);
}
});
}
function startDbMigration(tpl, direction) {
const which = direction === 'toFerretDB' ? 'FerretDB v1 (SQLite)' : 'MongoDB';
// eslint-disable-next-line no-alert
if (!window.confirm(TAPi18n.__('database-migration-confirm', { db: which }))) {
return;
}
tpl.migrateStatus.set({ running: true, phase: 'starting', direction });
Meteor.call('migrateTextDatabase', direction, error => {
if (error) {
tpl.migrateStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
} else {
pollMigrateStatus(tpl);
}
});
}
function pollBackupStatus(tpl) {
Meteor.call('backupStatus', (err, status) => {
if (err || !status) return;
tpl.backupStatus.set(status);
if (status.running) {
tpl.backupPoll = Meteor.setTimeout(() => pollBackupStatus(tpl), 2000);
}
});
}
Template.attachments.helpers({
loading() {
return Template.instance().loading;
@@ -298,6 +377,89 @@ Template.attachments.helpers({
isGcsActive() {
return Template.instance().activeSection.get() === 'gcs';
},
isDatabaseMigrationActive() {
return Template.instance().activeSection.get() === 'database-migration';
},
migrateStatus() {
return Template.instance().migrateStatus.get();
},
migrateRunning() {
const s = Template.instance().migrateStatus.get();
return !!(s && s.running);
},
migrateSuccess() {
const s = Template.instance().migrateStatus.get();
return !!(s && s.success === true);
},
isBackupActive() {
return Template.instance().activeSection.get() === 'backup';
},
isSandstorm() {
return !!(
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm
);
},
isSandstormActive() {
return Template.instance().activeSection.get() === 'sandstorm';
},
sandstormStatus() {
return Template.instance().sandstormStatus.get();
},
rawMongoSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.rawMongoBytes || 0) : '';
},
ferretSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.ferretBytes || 0) : '';
},
attachmentsSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.attachmentsBytes || 0) : '';
},
avatarsSize() {
const s = Template.instance().sandstormStatus.get();
return s ? filesize(s.avatarsBytes || 0) : '';
},
sandstormDeleteDisabled() {
const s = Template.instance().sandstormStatus.get();
// Only allow deleting the raw MongoDB files after a confirmed-successful
// migration, and only while they still exist.
return !(s && s.migrationSuccess && s.rawMongoExists);
},
sandstormDeleteResult() {
return Template.instance().sandstormDeleteResult.get();
},
sandstormDeleteFreed() {
const r = Template.instance().sandstormDeleteResult.get();
return r ? filesize(r.freedBytes || 0) : '';
},
sandstormDeleteError() {
return Template.instance().sandstormDeleteError.get();
},
backupStatus() {
return Template.instance().backupStatus.get();
},
backupRunning() {
const s = Template.instance().backupStatus.get();
return !!(s && s.running);
},
backupSuccess() {
const s = Template.instance().backupStatus.get();
return !!(s && s.success === true && s.phase === 'completed');
},
backupList() {
return Template.instance().backupList.get();
},
scheduleOff() { const s = Template.instance().backupSchedule.get(); return !s || !s.frequency || s.frequency === 'off'; },
scheduleDaily() { const s = Template.instance().backupSchedule.get(); return !!(s && s.frequency === 'daily'); },
scheduleWeekly() { const s = Template.instance().backupSchedule.get(); return !!(s && s.frequency === 'weekly'); },
scheduleMonthly() { const s = Template.instance().backupSchedule.get(); return !!(s && s.frequency === 'monthly'); },
scheduleTime() { const s = Template.instance().backupSchedule.get(); return (s && s.time) || '04:00'; },
scheduleDow() { const s = Template.instance().backupSchedule.get(); return (s && s.dayOfWeek) || 'Sunday'; },
scheduleDom() { const s = Template.instance().backupSchedule.get(); return (s && s.dayOfMonth) || 1; },
avatarsUploadBlocked() {
const settings = Template.instance().attachmentStorageSettings.get();
return settings?.limitSettings?.avatarsUploadBlocked === true;
@@ -384,16 +546,20 @@ Template.attachments.helpers({
isS3Active() {
return Template.instance().activeSection.get() === 's3';
},
// #6473: these paths come from the getAttachmentStoragePaths server method —
// WRITABLE_PATH is a server-side environment variable, so reading
// process.env here in the browser always produced the misleading "/data".
filesystemPath() {
return process.env.WRITABLE_PATH || '/data';
const paths = Template.instance().storagePaths.get();
return paths ? paths.writablePath : '';
},
attachmentsPath() {
const writablePath = process.env.WRITABLE_PATH || '/data';
return `${writablePath}/attachments`;
const paths = Template.instance().storagePaths.get();
return paths ? paths.attachments : '';
},
avatarsPath() {
const writablePath = process.env.WRITABLE_PATH || '/data';
return `${writablePath}/avatars`;
const paths = Template.instance().storagePaths.get();
return paths ? paths.avatars : '';
},
filesystemEnabled() {
const tpl = Template.instance();
@@ -561,6 +727,84 @@ Template.attachments.events({
tpl.activeSection.set(targetID);
},
'click .js-sandstorm-delete-raw-mongodb'(event, tpl) {
event.preventDefault();
if (!window.confirm(TAPi18n.__('sandstorm-delete-raw-mongodb-confirm'))) {
return;
}
tpl.sandstormDeleteError.set('');
Meteor.call('sandstormDeleteRawMongo', (error, result) => {
if (error) {
tpl.sandstormDeleteError.set(error.reason || error.message);
return;
}
tpl.sandstormDeleteResult.set(result);
// Refresh disk usage / status after deletion.
Meteor.call('sandstormMigrationStatus', (err, res) => {
if (!err && res) tpl.sandstormStatus.set(res);
});
});
},
'click .js-migrate-to-ferretdb'(event, tpl) {
event.preventDefault();
startDbMigration(tpl, 'toFerretDB');
},
'click .js-migrate-to-mongodb'(event, tpl) {
event.preventDefault();
startDbMigration(tpl, 'toMongoDB');
},
'click .js-run-backup'(event, tpl) {
event.preventDefault();
const opts = {
attachments: tpl.$('.js-backup-attachments').is(':checked'),
avatars: tpl.$('.js-backup-avatars').is(':checked'),
data: tpl.$('.js-backup-data').is(':checked'),
};
const storage = tpl.$('.js-backup-storage').val() || 'filesystem';
tpl.backupStatus.set({ running: true, phase: 'starting' });
Meteor.call('runBackup', opts, storage, error => {
if (error) tpl.backupStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
else pollBackupStatus(tpl);
});
},
'click .js-save-backup-schedule'(event, tpl) {
event.preventDefault();
const schedule = {
enabled: (tpl.$('.js-backup-frequency').val() || 'off') !== 'off',
frequency: tpl.$('.js-backup-frequency').val() || 'off',
time: tpl.$('.js-backup-time').val() || '04:00',
dayOfWeek: tpl.$('.js-backup-dow').val() || 'Sunday',
dayOfMonth: parseInt(tpl.$('.js-backup-dom').val(), 10) || 1,
attachments: tpl.$('.js-backup-attachments').is(':checked'),
avatars: tpl.$('.js-backup-avatars').is(':checked'),
data: tpl.$('.js-backup-data').is(':checked'),
storage: tpl.$('.js-backup-storage').val() || 'filesystem',
};
Meteor.call('saveBackupSchedule', schedule, (error, saved) => {
if (!error && saved) tpl.backupSchedule.set(saved);
});
},
'click .js-list-backups'(event, tpl) {
event.preventDefault();
Meteor.call('listBackups', (error, list) => {
if (!error) tpl.backupList.set(list || []);
});
},
'change .js-backup-select'(event, tpl) {
tpl.selectedBackup.set($(event.currentTarget).val());
},
'click .js-restore-backup'(event, tpl) {
event.preventDefault();
const zipPath = tpl.selectedBackup.get();
if (!zipPath) { window.alert(TAPi18n.__('backup-restore-select-first')); return; }
const mode = tpl.$('.js-restore-mode').val() || 'add-missing';
if (!window.confirm(TAPi18n.__('backup-restore-confirm'))) return;
tpl.backupStatus.set({ running: true, phase: 'restore' });
Meteor.call('restoreBackup', zipPath, mode, error => {
if (error) tpl.backupStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
else pollBackupStatus(tpl);
});
},
'change select.js-attachment-limit-unit'(event, tpl) {
const fieldName = event.currentTarget.dataset.field;
const selectedUnit = event.currentTarget.value;
@@ -29,15 +29,37 @@ template(name='statistics')
tr
th {{_ 'Node_version'}}
td {{statistics.process.nodeVersion}}
tr
th {{_ 'Database_type'}}
td {{statistics.mongo.databaseType}}
tr
th {{_ 'MongoDB_version'}}
td {{statistics.mongo.mongoVersion}}
tr
th {{_ 'Database_commit'}}
td {{statistics.mongo.databaseCommit}}
if statistics.mongo.ferretdbVersion
tr
th {{_ 'FerretDB_version'}}
td {{statistics.mongo.ferretdbVersion}}
tr
th {{_ 'FerretDB_commit'}}
td {{statistics.mongo.ferretdbCommit}}
tr
th {{_ 'MongoDB_storage_engine'}}
td {{statistics.mongo.mongoStorageEngine}}
tr
th {{_ 'MongoDB_Oplog_enabled'}}
td {{formatBoolean statistics.mongo.mongoOplogEnabled}}
tr
th {{_ 'Reactivity_mode'}}
td {{statistics.mongo.reactivity}}
tr
th {{_ 'Reactivity_order'}}
td {{statistics.mongo.reactivityOrder}}
tr
th {{_ 'DDP_transport'}}
td {{statistics.mongo.ddpTransport}}
tr
th {{_ 'OS_Type'}}
td {{statistics.os.type}}
+84 -25
View File
@@ -38,7 +38,7 @@ table tr:nth-child(even) {
}
.ext-box button {
min-width: 90px;
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
border: none;
border-radius: 0.4vw;
@@ -47,11 +47,11 @@ table tr:nth-child(even) {
}
.ext-box button:hover {
background: #005377;
background: var(--theme-accent, #005377);
}
.ext-box button:active {
background: #004766;
background: var(--theme-accent, #004766);
}
.content-wrapper {
margin-top: 10px;
@@ -93,30 +93,30 @@ table td:first-child {
min-width: auto;
}
#divAddOrRemoveTeam {
background: #01628c;
background: var(--theme-accent, #01628c);
display: none;
}
#addOrRemoveTeam {
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
}
#addOrRemoveTeam:hover {
background: #005377;
background: var(--theme-accent, #005377);
}
#divAddOrRemoveTeamContainer {
display: none;
margin: auto;
width: 50%;
border: 3px solid #01628c;
border: 3px solid var(--theme-accent, #01628c);
padding: 10px;
}
#cancelBtn {
margin-inline-start: 5% !important;
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
}
#cancelBtn:hover {
background: #005377;
background: var(--theme-accent, #005377);
}
#deleteAction {
margin-inline-start: 5% !important;
@@ -150,7 +150,7 @@ table td:first-child {
.unlock-all-btn {
margin-top: 20px;
margin-inline-start: 15px;
background-color: #01628c;
background-color: var(--theme-accent, #01628c);
color: #fff;
border: none;
border-radius: 0.4vw;
@@ -164,7 +164,7 @@ table td:first-child {
}
.unlock-all-btn:hover {
background-color: #005377;
background-color: var(--theme-accent, #005377);
}
.unlock-all-btn .fa {
@@ -185,7 +185,7 @@ table td:first-child {
top: 10%;
left: 50%;
transform: translateX(-50%);
background-color: #01628c;
background-color: var(--theme-accent, #01628c);
color: white;
padding: 10px 20px;
border-radius: 4px;
@@ -206,7 +206,7 @@ table td:first-child {
}
.text-green {
color: #01628c;
color: var(--theme-accent, #01628c);
}
.js-toggle-lock-status {
@@ -223,15 +223,16 @@ table td:first-child {
.people-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.people-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -256,15 +257,16 @@ table td:first-child {
.org-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.org-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -289,15 +291,16 @@ table td:first-child {
.team-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.team-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -378,3 +381,59 @@ table td:first-child {
.roles-settings .roles-description {
margin-bottom: 1rem;
}
/* Admin Panel > People > Domains table: search + column sort + pagination,
matching the Board Table view / other admin tables. */
.domain-controls {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.domain-controls input.js-domain-search {
flex: 1 1 auto;
height: 30px;
padding: 0 8px;
border: 1px solid #bbb;
border-radius: 0.4vw;
}
.domain-controls button.js-domain-search-button {
height: 30px;
padding: 0 12px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.domain-pagination {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.domain-pagination button {
min-width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.domain-pagination button.disabled {
opacity: 0.5;
pointer-events: none;
}
.domain-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
+40 -21
View File
@@ -122,7 +122,7 @@ template(name="people")
else if templatesSetting.get
+templatesGeneral
else if domainSetting.get
+domainGeneral(domainList=domainList)
+domainGeneral
template(name="orgGeneral")
@@ -146,21 +146,27 @@ template(name="orgGeneral")
th
| {{_ 'org-shared-templates'}}
br
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true") {{_ 'select-all'}}
| /
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false") {{_ 'unselect-all'}}
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'org-propagate-members-to-boards'}}
br
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
| /
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'org-sync-members-from-auth'}}
br
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
| /
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
tbody
tr
each org in orgList
@@ -187,21 +193,27 @@ template(name="teamGeneral")
th
| {{_ 'team-shared-templates'}}
br
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true") {{_ 'select-all'}}
| /
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false") {{_ 'unselect-all'}}
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'team-propagate-members-to-boards'}}
br
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
| /
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
th
| {{_ 'team-sync-members-from-auth'}}
br
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
| /
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true", title="{{_ 'select-all'}}")
i.fa.fa-check-square-o
|
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false", title="{{_ 'unselect-all'}}")
i.fa.fa-square-o
tbody
tr
each team in teamList
@@ -239,11 +251,18 @@ template(name="selectAllUser")
input.allUserChkBox(type="checkbox", id="chkSelectAll")
template(name="domainGeneral")
.domain-controls
input.js-domain-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
button.js-domain-search-button(type="button") {{_ 'search'}}
.domain-pagination
button.js-domain-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}") {{_ 'previous'}}
span.domain-page-info {{currentPage}} / {{totalPages}}
button.js-domain-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}") {{_ 'next'}}
table
thead
tr
th {{_ 'domain'}}
th {{_ 'domain-user-count'}}
th(scope="col") {{_ 'domain'}}
th(scope="col") {{_ 'domain-user-count'}}
tbody
if domainList.length
each domainList
+100 -38
View File
@@ -14,6 +14,7 @@ import InviteToBoardRolesSettings, {
const orgsPerPage = 25;
const teamsPerPage = 25;
const usersPerPage = 25;
const domainsPerPage = 25;
let userOrgsTeamsAction = ""; //poosible actions 'addOrg', 'addTeam', 'removeOrg' or 'removeTeam' when adding or modifying a user
let selectedUserChkBoxUserIds = [];
@@ -28,9 +29,9 @@ Template.people.onCreated(function () {
this.lockedUsersSetting = new ReactiveVar(false);
this.rolesSetting = new ReactiveVar(false);
this.templatesSetting = new ReactiveVar(false);
// #5850: Admin Panel > People > Domains tab.
// #5850: Admin Panel > People > Domains tab. The domains table itself
// (domainGeneral) now owns its data via getDomainsWithUserCountsPage.
this.domainSetting = new ReactiveVar(false);
this.domains = new ReactiveVar([]);
this.subscribe('inviteToBoardRolesSettings');
this.findOrgsOptions = new ReactiveVar({});
this.findTeamsOptions = new ReactiveVar({});
@@ -205,12 +206,9 @@ Template.people.onCreated(function () {
this.templatesSetting.set('templates-setting' === targetID);
this.domainSetting.set('domains-setting' === targetID);
// #5850: load the domains + per-domain user counts when the tab opens.
if ('domains-setting' === targetID) {
Meteor.call('getDomainsWithUserCounts', (err, res) => {
if (!err) this.domains.set(res || []);
});
}
// The Domains table (domainGeneral) now fetches its own single page from
// getDomainsWithUserCountsPage (server-side search / sort / pagination), so
// the parent no longer eagerly loads every domain into the browser.
// When switching to locked users tab, refresh the locked users list
if ('locked-users-setting' === targetID) {
@@ -292,9 +290,6 @@ Template.people.helpers({
domainSetting() {
return Template.instance().domainSetting;
},
domainList() {
return Template.instance().domains.get();
},
orgList() {
const tpl = Template.instance();
// The 'org' publication already returns only the current page (server-side
@@ -1176,31 +1171,30 @@ Template.modifyTeamsUsers.events({
selectedEltValue = selectedElt.options[selectedElt.selectedIndex].text;
selectedEltValueId = selectedElt.options[selectedElt.selectedIndex].value;
// #4593: `teams` is a forbidden field for direct client-side Users.update
// (see server/permissions/users.js: only the owner may update, and never
// `teams`), so the previous Users.update() calls here were silently denied
// by the server and the bulk team assignment never persisted — a user
// "added" to a team this way never saw the boards that team is assigned
// to. Use the admin-only `editUser` method instead, which persists the
// change and also grants the user membership of the boards the gained
// team is assigned to.
if(document.getElementById('addAction').checked){
for(let i = 0; i < selectedUserChkBoxUserIds.length; i++){
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
userTms = currentUser.teams;
if(userTms == undefined || userTms.length == 0){
userTms = [];
// Copy, so the cached minimongo document is not mutated in place.
userTms = (currentUser.teams || []).slice();
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
if(currUserTeamIndex == -1){
userTms.push({
"teamId": selectedEltValueId,
"teamDisplayName": selectedEltValue,
})
}
else if(userTms.length > 0)
{
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
if(currUserTeamIndex == -1){
userTms.push({
"teamId": selectedEltValueId,
"teamDisplayName": selectedEltValue,
});
}
});
}
Users.update(selectedUserChkBoxUserIds[i], {
$set:{
teams: userTms
Meteor.call('editUser', selectedUserChkBoxUserIds[i], { teams: userTms }, (error) => {
if (error) {
console.error('Error updating user teams:', error);
}
});
}
@@ -1208,18 +1202,15 @@ Template.modifyTeamsUsers.events({
else{
for(let i = 0; i < selectedUserChkBoxUserIds.length; i++){
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
userTms = currentUser.teams;
if(userTms !== undefined || userTms.length > 0)
{
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
if(currUserTeamIndex != -1){
userTms.splice(currUserTeamIndex, 1);
}
userTms = (currentUser.teams || []).slice();
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
if(currUserTeamIndex != -1){
userTms.splice(currUserTeamIndex, 1);
}
Users.update(selectedUserChkBoxUserIds[i], {
$set:{
teams: userTms
Meteor.call('editUser', selectedUserChkBoxUserIds[i], { teams: userTms }, (error) => {
if (error) {
console.error('Error updating user teams:', error);
}
});
}
@@ -1897,3 +1888,74 @@ Template.settingsUserPopup.helpers({
return Template.instance().errorMessage.get();
},
});
// Admin Panel > People > Domains table. Self-contained (like the Board Table
// view): it keeps its own search / sort / page state and fetches only ONE page
// from the server method getDomainsWithUserCountsPage, so the whole domain list
// is never loaded into the browser.
Template.domainGeneral.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.page = new ReactiveVar(1);
this.pageData = new ReactiveVar({ rows: [], total: 0, totalPages: 1 });
this.autorun(() => {
const params = {
search: this.searchQuery.get(),
page: this.page.get(),
perPage: domainsPerPage,
};
Meteor.call('getDomainsWithUserCountsPage', params, (err, res) => {
if (!err && res) {
this.pageData.set(res);
// Server clamps the page into range; mirror that so the controls agree.
if (typeof res.page === 'number' && res.page !== this.page.get()) {
this.page.set(res.page);
}
}
});
});
});
Template.domainGeneral.helpers({
domainList() {
return Template.instance().pageData.get().rows;
},
currentPage() {
return Template.instance().page.get();
},
totalPages() {
return Template.instance().pageData.get().totalPages || 1;
},
hasPrevPage() {
return Template.instance().page.get() > 1;
},
hasNextPage() {
const tpl = Template.instance();
return tpl.page.get() < (tpl.pageData.get().totalPages || 1);
},
});
Template.domainGeneral.events({
'click .js-domain-search-button'(event, tpl) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-domain-search').val() || '');
tpl.page.set(1);
},
'keydown .js-domain-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-domain-search').val() || '');
tpl.page.set(1);
}
},
'click .js-domain-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-domain-next-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current < (tpl.pageData.get().totalPages || 1)) tpl.page.set(current + 1);
},
});
@@ -0,0 +1,16 @@
template(name="problemsSummary")
.global-search-results-list-wrapper
h1 {{_ 'summary'}}
if hasProblems
p {{_ 'problems-summary-help'}}
.admin-problem-banner
each area in areas
label.admin-problem-item
input.js-problem-check(type="checkbox" data-stream="{{area.stream}}")
i.fa.fa-exclamation-triangle
span.admin-problem-path {{_ 'admin-panel'}} → {{_ 'problems'}} → {{areaLabel area.stream}}
span.admin-problem-count {{area.count}} {{_ 'new-problems'}}
.admin-problem-actions
button.js-ack-checked.primary {{_ 'acknowledge'}}
else
p {{_ 'no-new-problems'}}
@@ -0,0 +1,52 @@
import { TAPi18n } from '/imports/i18n';
// Admin Panel → Problems → Summary page: a checkbox list of problem areas
// (Security / Speed / Tests) with each area's menu path and its count of NEW
// (unacknowledged) problems, plus ONE Acknowledge button that acknowledges every
// checked area. This is the ONLY place problems are acknowledged — the Security /
// Speed / Tests detail pages are read-only.
// Design: docs/Security/Remediation/WeKan.md. Data: the admin-only
// eventLogProblemAreas / acknowledgeEventLog methods (models/eventLog.js).
const STREAM_LABEL_KEY = {
security: 'securityReportTitle',
speed: 'speedReportTitle',
tests: 'testsReportTitle',
};
Template.problemsSummary.onCreated(function () {
this.areas = new ReactiveVar([]);
this.reload = () => {
Meteor.call('eventLogProblemAreas', (err, res) => {
if (!err) this.areas.set(Array.isArray(res) ? res : []);
});
};
this.reload();
this.timer = Meteor.setInterval(this.reload, 30000);
});
Template.problemsSummary.onDestroyed(function () {
if (this.timer) Meteor.clearInterval(this.timer);
});
Template.problemsSummary.helpers({
areas() {
return Template.instance().areas.get();
},
hasProblems() {
return (Template.instance().areas.get() || []).length > 0;
},
areaLabel(stream) {
return TAPi18n.__(STREAM_LABEL_KEY[stream] || stream);
},
});
Template.problemsSummary.events({
'click .js-ack-checked'(event, templateInstance) {
const streams = Array.from(templateInstance.findAll('.js-problem-check:checked'))
.map(el => el.getAttribute('data-stream'))
.filter(Boolean);
if (!streams.length) return;
Meteor.call('acknowledgeEventLog', streams, () => templateInstance.reload());
},
});
+11 -5
View File
@@ -117,11 +117,17 @@
}
/* Force tables to always trigger horizontal scrolling */
.setting-content .content-body .main-body table td,
.setting-content .content-body .main-body table th {
.setting-content .content-body .main-body table td {
white-space: nowrap;
min-width: 120px;
}
/* #6465: headers may wrap — nowrap on long header labels (e.g. the org/team
feature columns) blew the column widths up to the whole label length. */
.setting-content .content-body .main-body table th {
white-space: normal;
min-width: 120px;
max-width: 240px;
}
/* Ensure there's enough content to trigger vertical scrolling */
.setting-content .content-body .main-body {
@@ -131,19 +137,19 @@
/* Admin panel buttons should use theme darker color */
.setting-content .content-body .main-body .setting-detail button.btn {
background: #005377;
background: var(--theme-accent, #005377);
color: #fff;
border: none;
}
.setting-content .content-body .main-body .setting-detail button.btn:hover,
.setting-content .content-body .main-body .setting-detail button.btn:focus {
background: #004766;
background: var(--theme-accent, #004766);
color: #fff;
}
.setting-content .content-body .main-body .setting-detail button.btn:active {
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
}
Loaded 100 of 860 files, more files were not shown because too many files have changed in this diff. Show more