Compare commits

...
9 Commits
25 changed files with 1021 additions and 138 deletions

No files matched your search

+59
View File
@@ -39,6 +39,63 @@ them up next.
[#3823](https://github.com/wekan/wekan/issues/3823), [#3138](https://github.com/wekan/wekan/issues/3138),
[#2204](https://github.com/wekan/wekan/issues/2204) (restrict permanent delete to the Admin role).
# v9.80 2026-07-06 WeKan ® release
This release adds the following new features:
- **[Admin Panel Domains table: pagination, column sort and search (like the Board Table view)](https://github.com/wekan/wekan/issues/5850)**:
The Admin Panel > People > Domains table loaded every domain (aggregated from all users) into the
browser at once, with a fixed order and no search. It now behaves like the Board Table view: the
server aggregates the domains and returns only one small page, so the whole list is never sent to
the browser. You can order by the Domain or Users column (click the header to toggle ascending /
descending, with a ▲/▼ indicator) and filter with a search box; prev/next controls page through the
results. The search + sort + slice runs in the new pure, unit-tested `models/lib/domainTablePage.js`
behind a new `getDomainsWithUserCountsPage` admin method (`server/models/users.js`), and the
`domainGeneral` template (`client/components/settings/peopleBody.{jade,js,css}`) is now
self-contained and fetches only the current page. Covered by `tests/domainTablePage.test.cjs`.
Thanks to xet7.
and adds the following tests:
- **[Verified and added a regression test for the board-invitation email language](https://github.com/wekan/wekan/commit/dfa6c78d4f032a698f6feaad189c86da903d27fb)**:
Confirmed that a board-invitation email is localised in the existing recipient's own profile
language, or — when the invitee is a new account created by the invite — in the inviter's profile
language, defaulting to `en` (`en.i18n.json`) when none is set. The behaviour was already correct;
the language choice is now extracted into the pure, unit-tested `models/lib/inviteEmailLanguage.js`
used by `inviteUserToBoard`, and locked in by `tests/inviteEmailLanguage.test.cjs`.
Thanks to xet7.
and fixes the following bugs:
- **[Linked-card minicard now shows the cover image of the real card](https://github.com/wekan/wekan/commit/9ef7f4a07a6b40a2582af24b27fe133119adcd18)**:
A linked card (created by "Link card to this card") on one board did not show the cover image of
the real card it points at on another board, even though the card's other fields did. A linked
card is only a placeholder — its real content lives on the card at `linkedId` — and every other
minicard getter resolves through the real card (`getTitle`/`getReceived`/`getDue`/…), but the
cover helpers read `this.coverId` directly, and a linked card has no `coverId` of its own. The
real card's cover attachment is already published to the linking board (see the "linked cards" /
"attachments for linked cards" children of the `board` publication), so this was purely a
client-side resolution gap. `Card.cover()` and the minicard `cover()` helper now resolve the
cover id through the real card via the pure, unit-tested `models/lib/linkedCardCover.js`; normal
cards are unaffected. Covered by `tests/linkedCardCover.test.cjs`.
Thanks to 32Dexter and xet7.
- **[Fix date-picker calendar stays fully visible when opened low on a scrolled page](https://github.com/wekan/wekan/commit/7a0eb61c0a4d3ace60f95cfbda7c1d70dff665ef)**:
Opening a date field (due/start/end date, or a date custom field) low on the screen showed the
calendar popup extending past the visible area, and — because the pop-over is `position: absolute`
(document coordinates) — scrolling to reach it moved the calendar along with the page, so the full
calendar could never be seen (the workaround was to close it, drag the field to the center and
reopen). `Popup._getOffset` computed the space above/below the opener and the clamped `top` from
the opener's DOCUMENT offset mixed with the VIEWPORT height, ignoring the page scroll, so on a
scrolled page the anchored popup landed outside the visible viewport. The geometry now runs in
viewport coordinates (subtracting the page scroll) and clamps the popup fully within the visible
viewport, then converts back to document coordinates for the absolute style; when the page is not
scrolled the output is unchanged. Extracted the math into the pure, unit-tested
`client/lib/popupOffset.js`, used by `client/lib/popup.js`. Covered by `tests/popupOffset.test.cjs`.
Thanks to MarcusDger and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.79 2026-07-06 WeKan ® release
This release fixes the following bugs:
@@ -92,6 +149,8 @@ This release fixes the following bugs:
sinon/chai script.
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.78 2026-07-06 WeKan ® release
This release fixes the following bugs:
+1 -1
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.79
ARG VERSION=9.80
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.79.0"
appVersion: "v9.80.0"
files:
userUploads:
- README.md
+7 -2
View File
@@ -6,6 +6,7 @@ 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,
@@ -168,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;
+59
View File
@@ -378,3 +378,62 @@ 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 #bbb;
background: #fff;
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;
}
th.js-domain-sort {
cursor: pointer;
user-select: none;
}
+10 -3
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")
@@ -239,11 +239,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.js-domain-sort(scope="col" data-sort="domain") {{_ 'domain'}} {{domainSortIndicator 'domain'}}
th.js-domain-sort(scope="col" data-sort="count") {{_ 'domain-user-count'}} {{domainSortIndicator 'count'}}
tbody
if domainList.length
each domainList
+99 -11
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
@@ -1897,3 +1892,96 @@ 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.sortField = new ReactiveVar('domain'); // domain | count
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.pageData = new ReactiveVar({ rows: [], total: 0, totalPages: 1 });
this.autorun(() => {
const params = {
search: this.searchQuery.get(),
sortField: this.sortField.get(),
sortDirection: this.sortDirection.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);
},
// Excel-like sort arrow on the active column, matching the Board Table view.
domainSortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 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);
},
'click .js-domain-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);
},
});
+27 -101
View File
@@ -4,6 +4,7 @@ import { Tracker } from 'meteor/tracker';
import { TAPi18n } from '/imports/i18n';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { computePopupOffset } from '/client/lib/popupOffset';
window.Popup = new (class {
constructor() {
@@ -222,108 +223,33 @@ window.Popup = new (class {
return () => {
Utils.windowResizeDep.depend();
if (Utils.isMiniScreen()) return { left: 0, top: 0 };
// #5667: read the current page scroll and the opener's document offset,
// then delegate the geometry to the pure, unit-tested computePopupOffset.
// It lays the popup out in VIEWPORT coordinates — so a date-picker opened
// low on a scrolled page stays fully visible instead of extending past the
// visible area — and returns document coordinates for the absolute style.
const hasOpener = !!($element && $element.length);
const offset = hasOpener ? $element.offset() : null;
const opener = hasOpener
? { top: offset.top, left: offset.left, height: $element.outerHeight() }
: null;
const viewportWidth = $(window).width();
const viewportHeight = $(window).height();
const viewportPadding = 10;
// Calculate actual popup width based on CSS: min(380px, 55vw)
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: dock it to the top of the viewport so it overlays
// the global and board header bars (its z-index already sits above them)
// instead of opening downward from the clicked minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
// top is forced to 0 in CSS (.pop-over[data-popup="cardDetailsPopup"])
// so the card overlays the header bars; use the full viewport height.
return {
left: Math.max(viewportPadding, centeredLeft),
top: 0,
maxHeight: viewportHeight,
};
}
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
if (!$element || $element.length === 0) {
return {
left: viewportPadding,
top: viewportPadding,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const offset = $element.offset();
const openerTop = offset.top;
const openerBottom = offset.top + $element.outerHeight();
const clampedLeft = Math.max(
viewportPadding,
Math.min(offset.left, viewportWidth - popupWidth - viewportPadding),
);
// Check if this is an admin panel edit popup
const isAdminEditPopup = $element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team');
if (isAdminEditPopup) {
// Center the popup horizontally and use full height
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft), // Ensure popup doesn't go off screen
top: viewportPadding, // Start from top with small margin
maxHeight: viewportHeight - viewportPadding * 2, // Use full height minus small margins
};
}
// Calculate available heights around the opener
const spaceBelow = viewportHeight - openerBottom - viewportPadding;
const spaceAbove = openerTop - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// For language popup, don't use dynamic height to avoid overlapping board
const isLanguagePopup = $element.hasClass('js-change-language');
let availableHeight;
if (isLanguagePopup) {
// For language popup, position content area below right vertical scrollbar
const languageTop = openerBottom;
const languageAvailable = Math.max(
0,
viewportHeight - languageTop - viewportPadding,
);
const calculatedHeight = Math.min(
languageAvailable,
viewportHeight * 0.5,
); // Max 50% of viewport
return {
left: clampedLeft,
top: languageTop,
maxHeight: Math.max(calculatedHeight, 0),
};
} else {
// For other popups, choose the side with more space and keep popup fully in viewport.
if (preferBelow) {
availableHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return {
left: clampedLeft,
top: openerBottom,
maxHeight: availableHeight,
};
}
availableHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const top = Math.max(viewportPadding, openerTop - availableHeight);
return {
left: clampedLeft,
top,
maxHeight: availableHeight,
};
}
return computePopupOffset({
viewportWidth: $(window).width(),
viewportHeight: $(window).height(),
scrollTop: $(window).scrollTop() || 0,
scrollLeft: $(window).scrollLeft() || 0,
opener,
popupName,
isMiniScreen: Utils.isMiniScreen(),
isAdminEditPopup:
hasOpener &&
($element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team')),
isLanguagePopup: hasOpener && $element.hasClass('js-change-language'),
viewportPadding: 10,
});
};
}
+118
View File
@@ -0,0 +1,118 @@
'use strict';
// Pure geometry for Popup._getOffset — extracted so the positioning math is
// unit-testable without a DOM / Meteor (see tests/popupOffset.test.cjs).
//
// #5667 ("Problem when scrolling in a map with date fields"): a date-picker
// calendar (due/start/end date, or a date custom field) opened low on the screen
// appeared partly outside the visible area, and — because the pop-over is
// `position: absolute` (document coordinates) — trying to scroll it into view
// moved it along with the page, so the calendar could never be seen fully.
//
// Root cause: _getOffset computed the space above / below the opener (and the
// clamped `top`) from the opener's DOCUMENT offset (`$el.offset()`) mixed with
// the VIEWPORT height, ignoring the page scroll. On a scrolled page that placed
// the anchored popup outside the visible viewport.
//
// Fix: do the space / clamp math in VIEWPORT coordinates (subtract
// scrollTop/scrollLeft), then convert the chosen top/left back to DOCUMENT
// coordinates (add the scroll back) for the absolute style. The popup box is then
// always laid out fully within the visible viewport. When the page is not
// scrolled (scrollTop === scrollLeft === 0) the output is identical to the
// previous behaviour, so nothing regresses in the common case.
function computePopupOffset(params) {
const {
viewportWidth,
viewportHeight,
scrollTop = 0,
scrollLeft = 0,
// Opener document offset { top, left, height }, or null for a programmatic
// open with no reference element.
opener = null,
popupName,
isMiniScreen = false,
isAdminEditPopup = false,
isLanguagePopup = false,
viewportPadding = 10,
} = params;
if (isMiniScreen) return { left: 0, top: 0 };
// Actual popup width from CSS: min(380px, 55vw).
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: docked to the top of the viewport (CSS also forces
// top:0) so it overlays the header bars instead of opening from the minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: scrollTop, // viewport top in document coords
maxHeight: viewportHeight,
};
}
// No opener element: fall back to the top-left of the VISIBLE viewport.
if (!opener) {
return {
left: viewportPadding + scrollLeft,
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
// Opener position in viewport coordinates (this is the #5667 fix: the previous
// code used the raw document offset here).
const openerTopVp = opener.top - scrollTop;
const openerBottomVp = openerTopVp + opener.height;
const openerLeftVp = opener.left - scrollLeft;
const clampedLeftVp = Math.max(
viewportPadding,
Math.min(openerLeftVp, viewportWidth - popupWidth - viewportPadding),
);
const clampedLeft = clampedLeftVp + scrollLeft;
// Admin panel edit popups: horizontally centered, anchored to the viewport top.
if (isAdminEditPopup) {
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const spaceBelow = viewportHeight - openerBottomVp - viewportPadding;
const spaceAbove = openerTopVp - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// Language popup: fixed-ish height below the opener, capped at 50% viewport.
if (isLanguagePopup) {
const languageTopVp = openerBottomVp;
const languageAvailable = Math.max(
0,
viewportHeight - languageTopVp - viewportPadding,
);
const calculatedHeight = Math.min(languageAvailable, viewportHeight * 0.5);
return {
left: clampedLeft,
top: languageTopVp + scrollTop,
maxHeight: Math.max(calculatedHeight, 0),
};
}
// Other popups: open on the side with more room and stay fully in the viewport.
if (preferBelow) {
const maxHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return { left: clampedLeft, top: openerBottomVp + scrollTop, maxHeight };
}
const maxHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const topVp = Math.max(viewportPadding, openerTopVp - maxHeight);
return { left: clampedLeft, top: topVp + scrollTop, maxHeight };
}
module.exports = { computePopupOffset };
+2 -2
View File
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-9.79-win64.zip](https://github.com/wekan/wekan/releases/download/v9.79/wekan-9.79-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-9.80-win64.zip](https://github.com/wekan/wekan/releases/download/v9.80/wekan-9.80-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-9.79-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-9.80-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+7 -2
View File
@@ -28,6 +28,7 @@ import {
TYPE_LINKED_CARD,
} from '../config/const';
import { CARD_COLORS } from '/models/metadata/colors';
import { resolveCoverId } from '/models/lib/linkedCardCover';
import {
DEFAULT_DEPENDENCY_COLOR,
DEFAULT_DEPENDENCY_ICON,
@@ -1171,8 +1172,12 @@ Cards.helpers({
},
cover() {
if (!this.coverId) return false;
const cover = ReactiveCache.getAttachment(this.coverId);
// #5666: resolve the cover id through the real card for linked cards (a
// linked card has no coverId of its own), matching the other linked-card
// getters; a plain card resolves to its own coverId.
const coverId = resolveCoverId(this, id => ReactiveCache.getCard(id));
if (!coverId) return false;
const cover = ReactiveCache.getAttachment(coverId);
// if we return a cover before it is fully stored, we will get errors when we try to display it
// todo XXX we could return a default "upload pending" image in the meantime?
return cover && cover.link() && cover;
+71
View File
@@ -0,0 +1,71 @@
'use strict';
// Pure filter + sort + paginate for the Admin Panel > People > Domains table.
// Extracted so the page logic is unit-testable without Meteor and shared by the
// server method that feeds the table.
//
// The Domains table used to load EVERY domain (aggregated from all users) into
// the browser at once, with a fixed order and no search. To behave like the
// Board Table view — send only one small page to the browser, let the user
// order by a column and search — the server aggregates all users into
// { domain, count } rows and then hands them to this helper, which applies the
// search filter, the requested column sort and the requested page, returning
// only that page plus the total so the client can render pagination controls.
const PER_PAGE_DEFAULT = 25;
const PER_PAGE_MAX = 200;
function paginateDomains(rows, opts = {}) {
const all = Array.isArray(rows) ? rows : [];
const search =
typeof opts.search === 'string' ? opts.search.trim().toLowerCase() : '';
// Only two real columns; anything else falls back to the domain name.
const sortField = opts.sortField === 'count' ? 'count' : 'domain';
const sortDirection = opts.sortDirection === -1 ? -1 : 1;
const rawPerPage = Number(opts.perPage);
const perPage = Math.min(
PER_PAGE_MAX,
Number.isFinite(rawPerPage) && rawPerPage >= 1 ? rawPerPage : PER_PAGE_DEFAULT,
);
let filtered = all;
if (search) {
filtered = all.filter(
row => (row.domain || '').toLowerCase().indexOf(search) !== -1,
);
}
const byDomain = (a, b) =>
(a.domain || '').localeCompare(b.domain || '', undefined, {
sensitivity: 'base',
numeric: true,
});
filtered = filtered.slice().sort((a, b) => {
if (sortField === 'count') {
const cmp = ((a.count || 0) - (b.count || 0)) * sortDirection;
if (cmp !== 0) return cmp;
// Ties always break alphabetically (ascending), independent of direction.
return byDomain(a, b);
}
return byDomain(a, b) * sortDirection;
});
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / perPage));
// Clamp the requested page into range so a stale/too-large page never yields
// an empty table.
const page = Math.min(Math.max(1, Number(opts.page) || 1), totalPages);
const start = (page - 1) * perPage;
return {
rows: filtered.slice(start, start + perPage),
total,
page,
totalPages,
perPage,
};
}
module.exports = { paginateDomains, PER_PAGE_DEFAULT, PER_PAGE_MAX };
+27
View File
@@ -0,0 +1,27 @@
'use strict';
// Pure helper choosing the language of the board-invitation email. Extracted
// from server/models/users.js (inviteUserToBoard) so the rule is explicit and
// unit-testable without Meteor.
//
// #5664 ("Change the email board invitation language"): the invite email must be
// localised as follows —
// - when the invitee is an ALREADY-EXISTING user, use THAT user's (the
// recipient's) profile language, so the person receiving the invite reads it
// in their own language;
// - when the invitee is a NEW user (created by the invite), use the INVITER's
// profile language, since the new account has no language preference of its
// own yet.
// A missing/empty language falls back to 'en' (i.e. en.i18n.json), matching
// User.getLanguage()'s default.
function chooseInviteEmailLanguage({
isNewUser,
inviterLanguage,
recipientLanguage,
} = {}) {
const lang = isNewUser ? inviterLanguage : recipientLanguage;
return lang || 'en';
}
module.exports = { chooseInviteEmailLanguage };
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// Pure helper for resolving a (possibly linked) card's cover attachment id.
// Extracted so the resolution is unit-testable without Meteor.
//
// #5666 ("Minicard connection without images"): a linked card
// (type 'cardType-linkedCard') is a placeholder on the linking board — its real
// content, including the cover image, lives on the card it points at (`linkedId`)
// on the other board. Every other minicard field resolves through the real card
// (getTitle/getReceived/getDue/... all do `getCard(this.linkedId)`), but the
// cover helpers read `this.coverId` directly. A linked card has no coverId of its
// own, so the cover never showed on the linking board's minicard even though the
// real card's attachment is published there. Resolve the cover id through the
// real card, mirroring the other linked-card getters.
const TYPE_LINKED_CARD = 'cardType-linkedCard';
// The card whose cover applies to `card`: the real card behind a link, otherwise
// the card itself. `getCard(id)` returns the card doc for an id (used to hop a
// linked card to its real card). Returns null when it can't be resolved.
function coverSourceCard(card, getCard) {
if (!card) return null;
if (card.type === TYPE_LINKED_CARD && card.linkedId) {
return typeof getCard === 'function' ? getCard(card.linkedId) || null : null;
}
return card;
}
// The cover attachment id to display for `card`, resolved through the real card
// for linked cards. Returns null when there is none (or the real card isn't
// loaded yet), so the caller renders no cover instead of throwing.
function resolveCoverId(card, getCard) {
const source = coverSourceCard(card, getCard);
return source && source.coverId ? source.coverId : null;
}
module.exports = { coverSourceCard, resolveCoverId };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v9.79.0",
"version": "v9.80.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v9.79.0",
"version": "v9.80.0",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.1071.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v9.79.0",
"version": "v9.80.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v9.79</title>
<title>WeKan REST API v9.80</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.79<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.80<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -1983,7 +1983,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.79","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.80","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v9.79
version: v9.80
description: |
The REST API allows you to control and extend Wekan with ease.
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 979,
appVersion = 980,
# Increment this for every release.
appMarketingVersion = (defaultText = "9.79~2026-07-06"),
appMarketingVersion = (defaultText = "9.80~2026-07-06"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+61 -1
View File
@@ -22,6 +22,8 @@ import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
import Users, { allowedSortValues, allowedAllBoardsSortValues } from '/models/users';
import { expiredNotificationActivityIds } from '/models/lib/notificationCleanup';
import { chooseInviteEmailLanguage } from '/models/lib/inviteEmailLanguage';
import { paginateDomains } from '/models/lib/domainTablePage';
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
const isSandstorm =
@@ -832,6 +834,8 @@ Meteor.methods({
const posAt = username.indexOf('@');
let user = null;
// #5664: existing invitee => their own language; new invitee => inviter's.
let isNewUser = false;
if (posAt >= 0) {
user = await ReactiveCache.getUser({
emails: { $elemMatch: { address: username } },
@@ -862,6 +866,7 @@ Meteor.methods({
}
Accounts.sendEnrollmentEmail(newUserId);
user = await ReactiveCache.getUser(newUserId);
isNewUser = true;
}
// #6116: when the global admin setting is enabled, only allow adding a user
@@ -961,7 +966,13 @@ Meteor.methods({
board: board.title,
url: board.absoluteUrl(),
};
const lang = user.getLanguage();
// #5664: pick the invite email's language — the existing recipient's own
// profile language, or (for a newly created invitee) the inviter's.
const lang = chooseInviteEmailLanguage({
isNewUser,
inviterLanguage: inviter.getLanguage(),
recipientLanguage: user.getLanguage(),
});
if (typeof EmailLocalization !== 'undefined') {
await EmailLocalization.sendEmail({
@@ -1847,6 +1858,55 @@ Meteor.methods({
.sort((a, b) => b.count - a.count || a.domain.localeCompare(b.domain));
},
// Paginated / searchable / column-sortable variant of getDomainsWithUserCounts
// for the Admin Panel Domains table. The aggregation still scans all users on
// the SERVER, but only one small page of rows is returned to the browser
// (matching the Board Table view). `paginateDomains` (pure, unit-tested) does
// the search + sort + slice; this method only does the auth check and the
// domain/count aggregation.
async getDomainsWithUserCountsPage(params = {}) {
check(params, Match.ObjectIncluding({
search: Match.Optional(String),
sortField: Match.Optional(String),
sortDirection: Match.Optional(Number),
page: Match.Optional(Number),
perPage: Match.Optional(Number),
}));
if (!this.userId) {
throw new Meteor.Error('not-logged-in', 'User must be logged in');
}
const currentUser = await ReactiveCache.getUser(
{ _id: this.userId },
{ fields: { isAdmin: 1 } },
);
if (!currentUser || !currentUser.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
const users = await Users.find({}, { fields: { emails: 1 } }).fetchAsync();
const counts = {};
for (const u of users) {
const addr = (u.emails && u.emails[0] && u.emails[0].address) || '';
const at = addr.lastIndexOf('@');
if (at === -1) continue;
const domain = addr.slice(at + 1).toLowerCase().trim();
if (!domain) continue;
counts[domain] = (counts[domain] || 0) + 1;
}
const rows = Object.keys(counts).map(domain => ({
domain,
count: counts[domain],
}));
return paginateDomains(rows, {
search: params.search,
sortField: params.sortField,
sortDirection: params.sortDirection,
page: params.page,
perPage: params.perPage,
});
},
// Feature #3313 "Shared templates": admin-only.
// For every user whose Templates board is NON-EMPTY (contains at least one
// shared template board), return the data the Admin Panel needs to group the
+4 -4
View File
@@ -1,5 +1,5 @@
name: wekan
version: '9.79'
version: '9.80'
base: core26
build-base: devel
confinement: strict
@@ -194,14 +194,14 @@ parts:
exit 1
;;
esac
WEKAN_ZIP="wekan-9.79-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-9.80-${WEKAN_ARCH}.zip"
NODE_TAR="node-v24.17.0-linux-${NODE_ARCH}.tar.xz"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v9.79/wekan-9.79-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.79/${WEKAN_ZIP}"
#wget https://github.com/wekan/wekan/releases/download/v9.80/wekan-9.80-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.80/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+99
View File
@@ -0,0 +1,99 @@
'use strict';
// Plain-Node unit test (no Meteor) for the Admin Panel Domains table paging.
// Run: node tests/domainTablePage.test.cjs
//
// The Domains table now behaves like the Board Table view: only one small page
// is sent to the browser, ordered by a column and filtered by a search term.
// paginateDomains() is the server-side search + sort + slice that backs it.
const assert = require('assert');
const { paginateDomains } = require('../models/lib/domainTablePage');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const rows = [
{ domain: 'gmail.com', count: 5 },
{ domain: 'example.com', count: 12 },
{ domain: 'apple.com', count: 3 },
{ domain: 'zeta.org', count: 12 },
{ domain: 'beta.io', count: 1 },
];
const names = r => r.rows.map(x => x.domain);
// --- Pagination (only a page is returned) -----------------------------------
test('returns only one page and the correct total', () => {
const r = paginateDomains(rows, { perPage: 2, page: 1, sortField: 'domain', sortDirection: 1 });
assert.strictEqual(r.total, 5);
assert.strictEqual(r.totalPages, 3);
assert.strictEqual(r.rows.length, 2);
});
test('page 2 returns the next slice; page beyond range clamps to the last page', () => {
// sorted asc: apple.com, beta.io, example.com, gmail.com, zeta.org
const p2 = paginateDomains(rows, { perPage: 2, page: 2, sortField: 'domain', sortDirection: 1 });
assert.deepStrictEqual(names(p2), ['example.com', 'gmail.com']);
const clamped = paginateDomains(rows, { perPage: 2, page: 99, sortField: 'domain', sortDirection: 1 });
assert.strictEqual(clamped.page, 3);
assert.strictEqual(clamped.rows.length, 1);
});
// --- Column sorting ----------------------------------------------------------
test('sort by domain ascending / descending', () => {
const asc = paginateDomains(rows, { perPage: 25, sortField: 'domain', sortDirection: 1 });
assert.deepStrictEqual(names(asc), ['apple.com', 'beta.io', 'example.com', 'gmail.com', 'zeta.org']);
const desc = paginateDomains(rows, { perPage: 25, sortField: 'domain', sortDirection: -1 });
assert.deepStrictEqual(names(desc), ['zeta.org', 'gmail.com', 'example.com', 'beta.io', 'apple.com']);
});
test('sort by count descending, tie-broken by domain name', () => {
const r = paginateDomains(rows, { perPage: 25, sortField: 'count', sortDirection: -1 });
// 12/12 tie -> example.com before zeta.org, then 5, 3, 1
assert.deepStrictEqual(names(r), ['example.com', 'zeta.org', 'gmail.com', 'apple.com', 'beta.io']);
});
test('an unknown sortField falls back to sorting by domain', () => {
const r = paginateDomains(rows, { perPage: 25, sortField: 'bogus', sortDirection: 1 });
assert.deepStrictEqual(names(r), ['apple.com', 'beta.io', 'example.com', 'gmail.com', 'zeta.org']);
});
// --- Search ------------------------------------------------------------------
test('search filters by domain substring (case-insensitive) and adjusts total', () => {
const r = paginateDomains(rows, { perPage: 25, search: 'COM', sortField: 'domain', sortDirection: 1 });
assert.deepStrictEqual(names(r), ['apple.com', 'example.com', 'gmail.com']);
assert.strictEqual(r.total, 3);
});
// --- NEGATIVE / edges --------------------------------------------------------
test('a search with no matches returns an empty page and total 0', () => {
const r = paginateDomains(rows, { perPage: 25, search: 'no-such-domain' });
assert.deepStrictEqual(r.rows, []);
assert.strictEqual(r.total, 0);
assert.strictEqual(r.totalPages, 1);
});
test('non-array / empty input is handled without throwing', () => {
assert.deepStrictEqual(paginateDomains(undefined).rows, []);
assert.deepStrictEqual(paginateDomains(null).rows, []);
const empty = paginateDomains([]);
assert.strictEqual(empty.total, 0);
assert.strictEqual(empty.page, 1);
});
test('perPage is clamped to a sane range (>=1, <=200)', () => {
assert.strictEqual(paginateDomains(rows, { perPage: 0 }).perPage, 25); // 0 -> default
assert.strictEqual(paginateDomains(rows, { perPage: 9999 }).perPage, 200);
assert.strictEqual(paginateDomains(rows, { perPage: -5 }).perPage, 25);
});
test('page is never below 1', () => {
const r = paginateDomains(rows, { perPage: 2, page: -3 });
assert.strictEqual(r.page, 1);
});
console.log(`\n${passed} tests passed`);
+86
View File
@@ -0,0 +1,86 @@
'use strict';
// Plain-Node unit test (no Meteor) for the board-invitation email language rule.
// Run: node tests/inviteEmailLanguage.test.cjs
//
// Locks in #5664: a board-invitation email is localised in the EXISTING
// recipient's own profile language, or — when the invitee is a NEW account
// created by the invite — in the INVITER's profile language, defaulting to 'en'
// (en.i18n.json) when no language is set.
const assert = require('assert');
const {
chooseInviteEmailLanguage,
} = require('../models/lib/inviteEmailLanguage');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- POSITIVE ---------------------------------------------------------------
test('#5664: existing invitee uses their own (recipient) language', () => {
assert.strictEqual(
chooseInviteEmailLanguage({
isNewUser: false,
inviterLanguage: 'de',
recipientLanguage: 'fr',
}),
'fr',
);
});
test('#5664: new invitee uses the inviter language', () => {
assert.strictEqual(
chooseInviteEmailLanguage({
isNewUser: true,
inviterLanguage: 'de',
recipientLanguage: 'fr', // must be ignored for a new user
}),
'de',
);
});
// --- NEGATIVE / defaults ----------------------------------------------------
test('existing invitee with no language defaults to en (en.i18n.json)', () => {
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: false, inviterLanguage: 'de', recipientLanguage: undefined }),
'en',
);
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: false, inviterLanguage: 'de', recipientLanguage: '' }),
'en',
);
});
test('new invitee with an inviter that has no language defaults to en', () => {
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: true, inviterLanguage: undefined, recipientLanguage: 'fr' }),
'en',
);
});
test('direction guard: an existing invitee never uses the inviter language', () => {
// Recipient has no language, inviter is German — must still be en, NOT de.
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: false, inviterLanguage: 'de', recipientLanguage: null }),
'en',
);
});
test('direction guard: a new invitee never uses the (blank) recipient language', () => {
// New account has no real preference; the inviter language wins over recipient.
assert.strictEqual(
chooseInviteEmailLanguage({ isNewUser: true, inviterLanguage: 'es', recipientLanguage: 'zz' }),
'es',
);
});
test('missing / empty arguments fall back to en without throwing', () => {
assert.strictEqual(chooseInviteEmailLanguage(), 'en');
assert.strictEqual(chooseInviteEmailLanguage({}), 'en');
});
console.log(`\n${passed} tests passed`);
+74
View File
@@ -0,0 +1,74 @@
'use strict';
// Plain-Node unit test (no Meteor) for the linked-card cover resolver.
// Run: node tests/linkedCardCover.test.cjs
//
// Regression guard for #5666 ("Minicard connection without images"): a linked
// card's cover image lives on the real card it points at, but the cover helpers
// read `this.coverId` directly — and a linked card has no coverId of its own, so
// the cover never showed on the linking board. resolveCoverId() must hop a linked
// card to its real card's coverId, while leaving normal cards unchanged.
const assert = require('assert');
const {
coverSourceCard,
resolveCoverId,
} = require('../models/lib/linkedCardCover');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Real card on board A with a cover; a linked card on board B points at it.
const realCard = { _id: 'realA', type: 'card', coverId: 'att-A' };
const linkedCard = { _id: 'linkB', type: 'cardType-linkedCard', linkedId: 'realA' };
const getCard = id => (id === 'realA' ? realCard : undefined);
// --- POSITIVE ---------------------------------------------------------------
test('#5666: a linked card resolves to the REAL card\'s cover id', () => {
assert.strictEqual(resolveCoverId(linkedCard, getCard), 'att-A');
assert.strictEqual(coverSourceCard(linkedCard, getCard), realCard);
});
test('a plain card resolves to its own cover id', () => {
assert.strictEqual(resolveCoverId(realCard, getCard), 'att-A');
});
test('a linked card ignores any stray coverId on the placeholder, using the real card', () => {
// Even if the linked-card placeholder somehow carried a coverId, the real
// card's cover is authoritative (guards the #5666 root cause directly).
const stray = { _id: 'linkB', type: 'cardType-linkedCard', linkedId: 'realA', coverId: 'stray' };
assert.strictEqual(resolveCoverId(stray, getCard), 'att-A');
});
// --- NEGATIVE ---------------------------------------------------------------
test('a plain card with no cover returns null', () => {
assert.strictEqual(resolveCoverId({ _id: 'x', type: 'card' }, getCard), null);
});
test('a linked card whose real card has no cover returns null', () => {
const realNoCover = { _id: 'realA', type: 'card' };
const linked = { _id: 'l', type: 'cardType-linkedCard', linkedId: 'realA' };
assert.strictEqual(resolveCoverId(linked, () => realNoCover), null);
});
test('a linked card whose real card is not loaded yet returns null (no throw)', () => {
const orphan = { _id: 'l', type: 'cardType-linkedCard', linkedId: 'missing' };
assert.strictEqual(resolveCoverId(orphan, getCard), null);
});
test('a linked card with no resolver returns null (no throw)', () => {
assert.strictEqual(resolveCoverId(linkedCard, undefined), null);
assert.strictEqual(coverSourceCard(linkedCard, undefined), null);
});
test('null / typeless cards are handled without throwing', () => {
assert.strictEqual(resolveCoverId(null, getCard), null);
assert.strictEqual(resolveCoverId(undefined, getCard), null);
assert.strictEqual(coverSourceCard(null, getCard), null);
});
console.log(`\n${passed} tests passed`);
+162
View File
@@ -0,0 +1,162 @@
'use strict';
// Plain-Node unit test (no DOM/Meteor) for the popup positioning geometry.
// Run: node tests/popupOffset.test.cjs
//
// Regression guard for #5667 ("Problem when scrolling in a map with date
// fields"): a date-picker opened low on a SCROLLED page used to be positioned
// from the opener's DOCUMENT offset mixed with the VIEWPORT height, so it landed
// partly outside the visible viewport (and, being position:absolute, scrolled
// away when the user tried to reach it). computePopupOffset must lay the popup
// out so it is fully within the visible viewport for any scroll position, while
// staying byte-identical to the old behaviour when the page is not scrolled.
const assert = require('assert');
const { computePopupOffset } = require('../client/lib/popupOffset');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const VW = 1200;
const VH = 800;
const PAD = 10;
// The popup box in VIEWPORT coordinates (result.top is a DOCUMENT coordinate).
function viewportBox(result, scrollTop) {
const topVp = result.top - scrollTop;
return { topVp, bottomVp: topVp + (result.maxHeight || 0) };
}
function assertFullyVisible(result, scrollTop, label) {
const { topVp, bottomVp } = viewportBox(result, scrollTop);
assert.ok(topVp >= 0, `${label}: top ${topVp} must be >= 0 (in viewport)`);
assert.ok(
bottomVp <= VH + 0.001,
`${label}: bottom ${bottomVp} must be <= viewportHeight ${VH}`,
);
}
// --- POSITIVE: fully visible regardless of scroll / opener position ---------
test('#5667: low opener on a scrolled page stays fully within the viewport', () => {
const scrollTop = 1000;
// Opener is near the BOTTOM of the viewport (viewport y ~= 720), so its
// document top is 1000 + 720.
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop,
opener: { top: scrollTop + 720, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assertFullyVisible(result, scrollTop, 'low+scrolled');
// A low opener has more room above, so it opens upward (bottom is at/above the opener).
assert.ok(result.top - scrollTop < 720, 'should open above the low opener');
});
test('#5667: opener near the top of a scrolled page does not overflow the top', () => {
const scrollTop = 1000;
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop,
opener: { top: scrollTop + 40, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assertFullyVisible(result, scrollTop, 'top+scrolled');
});
test('opener in the upper half opens below and stays visible', () => {
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop: 0,
opener: { top: 100, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assert.strictEqual(result.top, 124, 'opens just below the opener (100+24)');
assertFullyVisible(result, 0, 'upper-half');
});
test('scrollLeft keeps the popup within the viewport horizontally', () => {
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop: 0,
scrollLeft: 500,
opener: { top: 100, left: 500 + 1195, height: 24 }, // far right in viewport
popupName: 'editCardDueDatePopup',
});
const leftVp = result.left - 500;
const popupWidth = Math.min(380, VW * 0.55);
assert.ok(leftVp >= PAD, 'left within viewport');
assert.ok(leftVp + popupWidth <= VW - PAD + 0.001, 'right edge within viewport');
});
// --- Behaviour parity when NOT scrolled (no regression) ---------------------
test('unscrolled output equals the pre-fix behaviour (below case)', () => {
const r = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop: 0,
opener: { top: 200, left: 300, height: 24 }, popupName: 'x',
});
// Old code: top = openerBottom = 224; maxHeight = min(spaceBelow, 0.8*VH).
const spaceBelow = VH - 224 - PAD;
assert.strictEqual(r.top, 224);
assert.strictEqual(r.maxHeight, Math.min(spaceBelow, VH * 0.8));
});
test('special popups: cardDetails / no-opener / admin edit', () => {
const cd = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, scrollTop: 0, popupName: 'cardDetailsPopup' });
assert.strictEqual(cd.top, 0);
assert.strictEqual(cd.maxHeight, VH);
const none = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, scrollTop: 0, opener: null, popupName: 'x' });
assert.deepStrictEqual(none, { left: PAD, top: PAD, maxHeight: VH - PAD * 2 });
const mini = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, isMiniScreen: true, popupName: 'x' });
assert.deepStrictEqual(mini, { left: 0, top: 0 });
});
// --- NEGATIVE / the actual bug: the OLD formula overflowed when scrolled -----
test('#5667 regression: the OLD document-coord formula placed it OFF-SCREEN', () => {
const scrollTop = 1000;
const opener = { top: scrollTop + 40, left: 300, height: 24 }; // near viewport top
// Reproduce the pre-fix math (document offset mixed with viewport height).
const openerTop = opener.top;
const openerBottom = opener.top + opener.height;
const spaceBelow = VH - openerBottom - PAD;
const spaceAbove = openerTop - PAD;
const preferBelow = spaceBelow >= spaceAbove;
let oldTop, oldMax;
if (preferBelow) {
oldMax = Math.max(0, Math.min(spaceBelow, VH * 0.8));
oldTop = openerBottom;
} else {
oldMax = Math.max(0, Math.min(spaceAbove, VH * 0.8));
oldTop = Math.max(PAD, openerTop - oldMax);
}
const oldTopVp = oldTop - scrollTop;
// The old popup's TOP edge is far above the visible viewport (negative) — the bug.
assert.ok(oldTopVp < 0, `old top ${oldTopVp} should be off-screen (the bug)`);
// The fixed function keeps it on-screen for the exact same inputs.
const fixed = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop, opener, popupName: 'editCardDueDatePopup',
});
assertFullyVisible(fixed, scrollTop, 'fixed');
});
test('maxHeight is never negative for an opener above the viewport', () => {
const scrollTop = 1000;
const result = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop,
opener: { top: scrollTop - 500, left: 300, height: 24 }, // scrolled past, above viewport
popupName: 'editCardDueDatePopup',
});
assert.ok(result.maxHeight >= 0, 'maxHeight must never be negative');
});
console.log(`\n${passed} tests passed`);