mirror of
https://github.com/wekan/wekan.git
synced 2026-11-08 03:22:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2936a682ce | ||
|
|
ab80aaf9a3 | ||
|
|
0fb943b6fb | ||
|
|
6d8b0c15ec | ||
|
|
abc23a7eda | ||
|
|
d9aa210012 | ||
|
|
2e8e1e2ce2 | ||
|
|
c8bcc4817d | ||
|
|
2dd25c733e | ||
|
|
cb6c6533c5 | ||
|
|
292b804474 | ||
|
|
623fccf06e | ||
|
|
cfd7ad2e5f | ||
|
|
762e5a9432 | ||
|
|
f1d2a497bc | ||
|
|
2f9c972d46 | ||
|
|
582a22c291 | ||
|
|
d200234535 | ||
|
|
8548bae527 | ||
|
|
d6b2d55c36 | ||
|
|
f7e3533b13 | ||
|
|
e91c14cd9a | ||
|
|
e7439e1d45 | ||
|
|
4a34b91ecf | ||
|
|
d9984d2069 | ||
|
|
e982282954 | ||
|
|
e68938698c | ||
|
|
55299a5172 | ||
|
|
8bbc6fcda1 | ||
|
|
71463fb445 | ||
|
|
b60a1e5a85 | ||
|
|
114e9ecd5a | ||
|
|
c6739fe452 | ||
|
|
efd45c651e | ||
|
|
fdc2c57b8f | ||
|
|
2c75b95908 | ||
|
|
a3630d96ce | ||
|
|
db2aafe16c | ||
|
|
c4b90d08a0 | ||
|
|
cb5278ddb3 | ||
|
|
bc79de8c57 | ||
|
|
81714cd695 | ||
|
|
d42369e96a | ||
|
|
efff590f74 | ||
|
|
64b5762020 | ||
|
|
c29a59eb95 | ||
|
|
dcaa316a01 | ||
|
|
7721c5f655 | ||
|
|
492895200b | ||
|
|
1b346dc465 | ||
|
|
45c291b406 | ||
|
|
c3af2a08a9 | ||
|
|
21c94ce098 | ||
|
|
2d8a28c2f6 | ||
|
|
c58b2896d1 |
No files matched your search
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": [
|
||||
"./tsconfig.lint.json"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error"
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,404 @@ its reason.
|
||||
|
||||
</details>
|
||||
|
||||
# v12.85 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** The findings of the **accessibility audit** are fixed in the
|
||||
default theme: header and label **colour contrast**, names that contain the
|
||||
visible text, named form fields, card section headings that are real buttons,
|
||||
and landmarks and headings on every page. axe-core now finds no violation on
|
||||
22 pages. A cleared database connection pool is reported as a lost connection.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Accessibility** - what an automated axe-core scan of the default theme found
|
||||
(see Audit.md in docs/Features/General/Accessibility).
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/c8bcc4817d">axe-core finds no violation on 22 pages of the default theme</a>. Thanks to xet7.</summary>
|
||||
|
||||
The audit of 2026-11-02 found, on seven pages, colour contrast of the header
|
||||
bars and the labels (WCAG 1.4.3), a Label in Name mismatch on the Starred menu,
|
||||
the user menu and the avatars (2.5.3), form fields without a name (4.1.2),
|
||||
headings that were buttons holding other controls (4.1.2), and missing
|
||||
landmarks and headings. Each is fixed, and the scan, now on 22 pages (the
|
||||
signed-out ones, All Boards and its lists, the other pages, the Admin Panel, a
|
||||
board and an opened card), finds nothing.
|
||||
|
||||
The default theme's blue is `#2573a7` instead of `#2980b9` everywhere the
|
||||
stylesheets, the accent table and the colour picker name it, so white and the
|
||||
`#f2f2f2` button labels reach 5.14 and 4.59. The label colours green, red, sky,
|
||||
magenta and gray and the Global search list chips have black text. The quiet
|
||||
greys `#8c8c8c` and `#727479`, the "no items" text and the grey board tiles
|
||||
are darker. The Starred menu, the user menu link and an avatar drawn as initials
|
||||
have those letters or numbers in their accessible name. The Pomodoro field and
|
||||
the comment, description and edit-comment editors have a name (the editor takes
|
||||
a `labelKey`). A section heading of the card details is an `h3` holding a real
|
||||
`button` with `aria-expanded`; the section menu and the layout toggle are
|
||||
beside the button, and Enter, Space and a click on the heading still fold the
|
||||
section. The sign-in and register form is the `main` landmark, its title is the
|
||||
level-one heading and the empty logo `h1` is a spacer; every other page's title
|
||||
in the header bar is the level-one heading, Activity, Import and Bookmarks have
|
||||
one, and the shortcuts list can be reached by keyboard.
|
||||
|
||||
`tests/accessibilityAuditFixes.test.cjs` pins each fault by its shape (contrast
|
||||
ratios are computed from the stylesheets), and
|
||||
`tests/playwright/specs/45-axe-accessibility.e2e.js` runs axe-core on the pages
|
||||
and fails on any violation. Other board colours and themes, the phone width,
|
||||
dark mode, popups, and the views not scanned still need the same check.
|
||||
|
||||
</details>
|
||||
|
||||
**Database problems** - how Admin Panel -> Problems and the startup read the
|
||||
database.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/abc23a7eda">A cleared connection pool is a lost connection, and the board key backfill uses the key index</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6860](https://github.com/wekan/wekan/issues/6860). While a MongoDB pod
|
||||
restarted, a method's "Connection pool for <host> was cleared" reached Admin
|
||||
Panel -> Problems as "did not match its schema": collection2 wraps a failed
|
||||
write as "ValidationError: Failed validation" plus the driver's text, so the
|
||||
class name PoolClearedError was gone, and the host in the text is a replica set
|
||||
member's name (wekan-mongodb-0.wekan-mongodb-internal in a StatefulSet) that
|
||||
MONGO_URL does not list, so the "getaddrinfo ENOTFOUND" behind it did not count
|
||||
as the database's. The text itself is now a lost connection, for the Problems
|
||||
page and for both startup guards, and a message about a pool setting is not.
|
||||
The board key backfill looked for boards without a key with an `$or` that
|
||||
included an exists-false branch, a scan of every board at every start of every
|
||||
server (32,600 boards in the report); `key: null` already matches a missing
|
||||
field, so one `$in` on the `key` index finds the same boards, and the same
|
||||
selector still guards the writes, so a key a board admin set meanwhile is never
|
||||
replaced. `tests/databaseErrorsWrappedCause.test.cjs` and
|
||||
`tests/boardKeyBackfill.test.cjs` pin both, and that no exists-false branch on
|
||||
`key` is left.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.84 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** GitHub code scanning's **code-quality** findings are fixed: a
|
||||
promise formatted as text, undeclared globals, an upload that hung on an
|
||||
unreadable file, and the lint around them. `npm run lint:promises`
|
||||
now finds promises that are not awaited or handled.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Code quality** - findings of GitHub code scanning.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/582a22c291">The real faults among the code-quality findings, and the lint around them</a>. Thanks to GitHub CodeQL and xet7.</summary>
|
||||
|
||||
A card's original-position description formatted a promise, because it did
|
||||
not await the history it reads. The attachment swipe distances and two
|
||||
drag-scroll locals were undeclared globals. A file that cannot be read during
|
||||
a GridFS upload now unlinks itself instead of leaving the upload open, since
|
||||
`pipe()` does not pass a read error on. `api.py` called `exit()`, which is
|
||||
missing when the site module is not loaded, and its usage branch never
|
||||
exited; it uses `sys.exit()`. A rules test redefined a property and a
|
||||
security-log test kept an unreachable branch. Besides these: 31 missing
|
||||
semicolons, unused imports in the card details, the exporters and the
|
||||
attachment API, unused Python imports, and a comment on each empty except
|
||||
clause. The list-sync scanner test cut its source at the last
|
||||
`Meteor.startup`, which the index startup of the schema-scan fix had moved;
|
||||
it now cuts at the first one after the scanner. Two unhandled rejections are
|
||||
caught as well: the instance-editing
|
||||
hook's board lookup and the service worker registration.
|
||||
`tests/codeqlFindingsRegression.test.cjs` pins the shape of each fault.
|
||||
Findings in vendored bundles (the Office viewer, Jade, capnp-es, exceljs)
|
||||
and style-only ones are left as they are.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following developer-tooling fixes:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/f1d2a497bc">npm run lint:promises finds promises that are not awaited, handled or marked void</a>. Thanks to xet7.</summary>
|
||||
|
||||
GitHub code scanning's "Missing await" finding was a real fault in
|
||||
`models/cards.js` (a description formatted a promise), and a plain ESLint
|
||||
cannot see that kind of mistake because it does not know which calls return a
|
||||
promise. `npm run lint:promises` runs typescript-eslint's
|
||||
`no-floating-promises` and `no-misused-promises` over `models/lib`,
|
||||
`server/lib` and `imports/lib`, with the type information read from
|
||||
`tsconfig.lint.json`; the code stays JavaScript and nothing is type-checked.
|
||||
The eleven findings were promises that already catch their own errors, now
|
||||
marked `void`, and three checks that used a promise as a boolean.
|
||||
`tests/promiseLint.test.cjs` keeps the configuration in place and the tree
|
||||
clean.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.83 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** An **LDAP** login with a reused login name no longer opens the
|
||||
previous owner's account, and existing accounts record their directory
|
||||
identifier.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**LDAP** - matching a directory entry to a WeKan account at login, import and
|
||||
background sync.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/e91c14cd9a">A reused login name no longer opens the previous owner's account</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6859](https://github.com/wekan/wekan/issues/6859). The login looked for the
|
||||
account by the entry's unique identifier, then by username, then with
|
||||
LDAP_EMAIL_MATCH_ENABLE by e-mail, and the last two did not check whether the
|
||||
account already belonged to another directory entry. When a directory gave a
|
||||
deleted employee's sAMAccountName to a new colleague, the new colleague logged
|
||||
into the old account and saw its boards, cards and comments; two entries
|
||||
sharing an e-mail address did the same, and so did the import with
|
||||
LDAP_MERGE_EXISTING_USERS. An account that records an identifier of the same
|
||||
attribute with a different value is now refused at login ("LDAP
|
||||
authentication failed") and skipped by the import. An account without an
|
||||
identifier, with an identifier of another attribute, or with an identifier
|
||||
without its attribute (made before \#4654) is still accepted, so switching
|
||||
LDAP_UNIQUE_IDENTIFIER_FIELD to objectGUID locks nobody out. When that field is
|
||||
empty the identifier is the login name itself, so a reused name cannot be
|
||||
detected: set it to objectGUID or entryUUID.
|
||||
|
||||
Three more faults in the same matching are fixed. An existing account never
|
||||
recorded services.ldap.id and its attribute (only a new account did), so
|
||||
accounts from before \#4654 never got an attribute and a changed
|
||||
LDAP_UNIQUE_IDENTIFIER_FIELD never took effect; the login and the sync now
|
||||
record both. LDAP_EMAIL_MATCH_REQUIRE looked the user up with _id equal to the
|
||||
username, which never matches a random id, so every existing user's login
|
||||
tried to create the account again; it uses the username. The background sync
|
||||
bound an account without an identifier to the first of several entries with
|
||||
its name; it now skips that account, neither updating nor disabling it.
|
||||
`tests/ldapAccountBinding.test.cjs` pins each of these, and that no lookup by
|
||||
_id equal to the username is left in the package and no name or e-mail match
|
||||
reaches the login without the check. A refused login is not recorded in
|
||||
Admin Panel -> Problems: the LDAP package cannot reach the application's
|
||||
security log, and the new colleague who is refused is not an attacker.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.82 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** A card moved to another board, restored from the archive or given
|
||||
a number no longer takes a **card number** another card on that board has.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Card numbers** - the number each card has on its board.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/e68938698c">A moved, restored or renumbered card cannot take a number another card has</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6858](https://github.com/wekan/wekan/issues/6858). Follow-up to
|
||||
[\#6815](https://github.com/wekan/wekan/issues/6815), which checked the number
|
||||
of every new card. A card also enters a board's numbering through an update: a
|
||||
move to another board, where the browser computed the number from the
|
||||
destination cards it had loaded, usually none, so the card got 1 or another
|
||||
low number already taken; a restore from the archive, since an archived card
|
||||
may share its number with an open one; and a number set by the REST API or an
|
||||
import. Three card numbers used by more than one open card showed up again in
|
||||
Admin Panel -> Problems -> Data health two days after the renumbering. A
|
||||
before-update hook now does what the insert hook does: a number another card
|
||||
on that board has is replaced by a fresh one from the board's counter, after
|
||||
raising the counter to the board's highest number; a free number is kept and
|
||||
the counter raised past it. The card itself never counts as taken, and
|
||||
ordinary edits, moves within a board, archiving and cards without a number
|
||||
are left alone. `tests/cardNumberUnique6815.test.cjs` pins which updates are
|
||||
checked, the move, the restore and the free numbers.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.81 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** The **database health** probe reports a finding once, not once
|
||||
per server.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Database health** - the probe behind Admin Panel -> Problems -> Database.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/114e9ecd5a">One server probes the database per period, so a finding is reported once</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6857](https://github.com/wekan/wekan/issues/6857). Every server read the
|
||||
same MongoDB log and counters every five minutes and recorded each finding
|
||||
itself, so one collection scan became up to 24 identical rows with 24
|
||||
replicas - more than 400 warnings over 68 pages in the report. A probe now
|
||||
claims the period in one shared document (collection databaseHealthProbe);
|
||||
only the server that gets it probes, a duplicate key means another server has
|
||||
it. The state that compares one probe with the next - log lines seen, the
|
||||
scanned-documents counter, uptime, restarts, latencies - lives in that
|
||||
document, so the next probe on whichever server continues where the last one
|
||||
stopped. A server without the claim still checks its own data directory, and
|
||||
a claim that cannot be made at all does not stop the probe.
|
||||
`tests/databaseHealthProbeClaim.test.cjs` pins the claim, the shared state
|
||||
and that each finding is reported once.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.80 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** **minimatch** is updated to 11.0.4 for GHSA-g3hv-75wg-m249, a
|
||||
glob pattern that used up CPU and memory.
|
||||
|
||||
This release updates the following dependencies:
|
||||
|
||||
- **minimatch 10.2.6 → 11.0.4** — Dependabot alerts #180 and #182,
|
||||
[GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249)
|
||||
(CVE-2026-118764, CWE-405, moderate): before 11.0.1 a crafted glob pattern
|
||||
made it grow its glob parts without bound and compare them pairwise, using
|
||||
up CPU and memory. It came in through archiver, typescript-eslint and
|
||||
Babel's CLI; all of them now use 11.0.4, and
|
||||
`tests/minimatchGlobparts.test.cjs` fails if a 10.x copy returns
|
||||
([commit](https://github.com/wekan/wekan/commit/a3630d96ce)).
|
||||
Thanks to dependabot.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.79 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** A cluster of servers runs the **schema re-check** and the
|
||||
**due-date reminders** once, and a slow-query warning says which query it is.
|
||||
The site **announcement** shows on Home and All
|
||||
Boards too. The **date picker** popup shows the year and has year, month and
|
||||
day drop-downs under the calendar. The **macOS sandbox** for VSCodium no longer
|
||||
asks for a keychain, starts from the checkout, and can carry a **Claude Code**
|
||||
token so its login sticks.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Database load** - work that every server of a cluster repeated.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d42369e96a">The schema re-check and the due-date reminders run once, not on every server</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6855](https://github.com/wekan/wekan/issues/6855). With 24 replicas on one
|
||||
database every start of every server re-checked the schema, reading 1.1
|
||||
million cards several times, and each server sent its own due-date reminder
|
||||
for every card. The re-check now claims a run in the migration collection, so
|
||||
one server does it and the others skip; a claim older than 2 hours, from a
|
||||
server killed mid-run, is taken over. A run that leaves work it cannot do
|
||||
(an attachment whose file is gone) is retried once a day instead of at every
|
||||
start, and a clean run clears that. The daily reminder scan is claimed per day
|
||||
in the dueReminderRuns collection, so the first server scans and the rest get
|
||||
a duplicate key and skip. A partial index on lists serves the 15-minute list
|
||||
sync scan, and a sparse index on boards.dueReminderDays the due-date lookup.
|
||||
`tests/schemaUpgradeSteps.test.cjs` and `tests/dueScanClaim.test.cjs` pin the
|
||||
claims, the stale takeover, the daily retry and that the scheduler claims
|
||||
before it scans.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/bc79de8c57">A collection-scan row names the command, the operators per field and the queryHash</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6856](https://github.com/wekan/wekan/issues/6856). Admin Panel -> Problems
|
||||
-> Database named only the collection and the filtered fields, for example
|
||||
"wekan.lists (filter on archived,syncSource.enabled,syncSource.type)", which
|
||||
does not say which query it is, and a slow getMore read "filter on nothing".
|
||||
The row now shows the command (find, distinct, aggregate, ...), for a getMore
|
||||
the query it continues, each field with the operators used on it - for example
|
||||
`archived:$exists` or `$nor[sort:$gte$lte]` - and MongoDB's queryHash, so
|
||||
the query can be found in the code and with explain(). Values never reach the
|
||||
row. `tests/databaseHealthScans.test.cjs` pins the shape, the queryHash, the
|
||||
getMore origin and that no value leaks.
|
||||
|
||||
</details>
|
||||
|
||||
**Announcement** - the site announcement of Admin Panel -> Announcement.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/64b5762020">The announcement shows on Home and on every All Boards list</a>. Thanks to titver968 and xet7.</summary>
|
||||
|
||||
[\#6854](https://github.com/wekan/wekan/issues/6854). The banner waited
|
||||
for the user to be a member of the current board, and Home, Remaining, Starred,
|
||||
Templates and the other All Boards pages have no current board, so it showed
|
||||
only on boards. It now shows to every signed-in user on those pages, and to the
|
||||
members of the open board on a board page; a visitor who is not signed in still
|
||||
does not see it (#1281).
|
||||
`tests/announcementAudience.test.cjs` pins the rule and that no membership test
|
||||
of a current board is left in front of the banner.
|
||||
|
||||
</details>
|
||||
|
||||
**Date picker** - the calendar in the Received, Start, Due and End date popups.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/45c291b406">The calendar heading shows the year as well as the month</a>. Thanks to brlin-tw and xet7.</summary>
|
||||
|
||||
[\#6853](https://github.com/wekan/wekan/issues/6853). The popup calendar
|
||||
heading named only the month, so after the arrows had moved past December
|
||||
there was no sign of which year was shown; the selected date beside it does
|
||||
not change when the month does. The heading now reads for example "January
|
||||
2027", in every calendar system. `tests/calendarPickerControls.test.cjs` pins
|
||||
the year in the heading and that no picker mode leaves it out again, and the
|
||||
Playwright date display spec expects it.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/dcaa316a01">Any year by number, and month and day drop-downs, under the calendar</a>. Thanks to brlin-tw and xet7.</summary>
|
||||
|
||||
[\\#6853](https://github.com/wekan/wekan/issues/6853). The heading above the
|
||||
calendar now shows only the month and the year, no longer the YYYY-MM-DD of
|
||||
the selected day. Under the calendar are three controls: a year field, where
|
||||
any whole year can be typed or stepped to - thousands of years back or ahead,
|
||||
0 being 1 BC and -2999 3000 BC, as far as a date reaches (about 275,000 years
|
||||
either side of 1970) - a drop-down of the months of that year, and one of the
|
||||
days of that month. A pick selects the date and shows its month; Enter in the
|
||||
year field applies the year instead of saving the popup, and a value that is
|
||||
not a year puts the field back. The months and days are read from the calendar
|
||||
system in use, so they hold for the Islamic, Hebrew, Japanese and every other
|
||||
system, including leap months and a short thirteenth month. A day that the new
|
||||
month lacks falls to its last day. Calendars whose eras restart or switch the
|
||||
count (Japanese, ROC, Coptic, Ethiopic) refuse a year no number can name.
|
||||
`tests/calendarDropdowns.test.cjs` checks ten calendar systems and that no
|
||||
date text is left in the heading.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following developer-tooling fixes:
|
||||
|
||||
**The macOS sandbox** - running VSCodium and Claude Code in the sandbox on a
|
||||
Mac.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/c58b2896d1">VSCodium no longer asks for a keychain in the macOS sandbox</a>. Thanks to xet7.</summary>
|
||||
|
||||
The sandbox refuses `~/Library/Keychains`, so VSCodium's secret storage asked
|
||||
for a keychain to keep "VSCodium Key" in, with a "Reset To Defaults" button
|
||||
that would have replaced the login keychain. `sandbox.sh` now starts VSCodium
|
||||
with `--use-mock-keychain`, so the real Keychain is never touched. The README
|
||||
explains the price: extension secrets in `.tools/vscodium/user-data` are only
|
||||
obfuscated. `tests/macosSandbox.test.cjs` covers the sandbox script.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/21c94ce098">Claude Code stays signed in inside the macOS sandbox, and the sandbox starts in the checkout</a>. Thanks to xet7.</summary>
|
||||
|
||||
Claude Code keeps its login in the Keychain, which the sandbox cannot reach.
|
||||
`sandbox.sh --claude-token` now saves a token from `claude setup-token` to
|
||||
`.tools/secrets/claude-oauth-token` (read from the clipboard, line breaks
|
||||
joined, a cut-short token refused) and passes it as `CLAUDE_CODE_OAUTH_TOKEN`,
|
||||
with onboarding marked done and the extension's login prompt off. The script
|
||||
also changes to the checkout first, because Finder starts it in the home
|
||||
directory, which the sandbox refuses ("EPERM: process.cwd failed"), and starts
|
||||
VSCodium with `nohup` so closing the launcher's Terminal does not close it.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.78 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** **CrossBoardExportBleed** and **UnassignedAttachBleed** are
|
||||
|
||||
+26
-32
@@ -5,49 +5,43 @@ each one changed and the project status are in
|
||||
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
|
||||
make changes there.
|
||||
|
||||
# v12.78 2026-11-02 WeKan ® release
|
||||
# v12.85 2026-11-02 WeKan ® release
|
||||
|
||||
**CrossBoardExportBleed** and **UnassignedAttachBleed** are
|
||||
fixed: a checklist scope stays on its board, every export obeys Disable all
|
||||
export and Anonymize export users, and an assigned-only member changes
|
||||
attachments only on their own cards. **SAML** logins use **node-saml 5.2.0**,
|
||||
and **continuous backup** no longer misses a change made right after it starts.
|
||||
**Keyboard shortcuts** keep working after a popup closes, and the Rules page has
|
||||
a way back to the board.
|
||||
The findings of the **accessibility audit** are fixed in the
|
||||
default theme: header and label **colour contrast**, names that contain the
|
||||
visible text, named form fields, card section headings that are real buttons,
|
||||
and landmarks and headings on every page. axe-core now finds no violation on
|
||||
22 pages. A cleared database connection pool is reported as a lost connection.
|
||||
|
||||
[All changes in v12.78](CHANGELOG-big.md#v1278-2026-11-02-wekan--release)
|
||||
[All changes in v12.85](CHANGELOG-big.md#v1285-2026-11-02-wekan--release)
|
||||
|
||||
# v12.77 2026-10-31 WeKan ® release
|
||||
# v12.84 2026-11-02 WeKan ® release
|
||||
|
||||
Two **node test guards** follow the movable popups and the date
|
||||
picker of v12.76.
|
||||
GitHub code scanning's **code-quality** findings are fixed: a
|
||||
promise formatted as text, undeclared globals, an upload that hung on an
|
||||
unreadable file, and the lint around them. `npm run lint:promises`
|
||||
now finds promises that are not awaited or handled.
|
||||
|
||||
[All changes in v12.77](CHANGELOG-big.md#v1277-2026-10-31-wekan--release)
|
||||
[All changes in v12.84](CHANGELOG-big.md#v1284-2026-11-02-wekan--release)
|
||||
|
||||
# v12.76 2026-10-31 WeKan ® release
|
||||
# v12.83 2026-11-02 WeKan ® release
|
||||
|
||||
Security features: a **password policy** with an optional
|
||||
breached-password check, **Admin Panel / Problems / Password length**,
|
||||
**compulsory two-factor authentication**, sign-out of **idle sessions** and of
|
||||
every session after a password change, an **audit log** of what administrators
|
||||
did, **HSTS**, a cross-site request guard, and **S3 encryption at rest**.
|
||||
**Sign-up** works again; the date picker has **Today**, the whole card title
|
||||
edits and **every popup can be moved**; and an old **Sandstorm** grain migrated
|
||||
from MongoDB 3 shows its boards again.
|
||||
An **LDAP** login with a reused login name no longer opens the
|
||||
previous owner's account, and existing accounts record their directory
|
||||
identifier.
|
||||
|
||||
[All changes in v12.76](CHANGELOG-big.md#v1276-2026-10-31-wekan--release)
|
||||
[All changes in v12.83](CHANGELOG-big.md#v1283-2026-11-02-wekan--release)
|
||||
|
||||
# v12.75 2026-10-30 WeKan ® release
|
||||
# v12.82 2026-11-02 WeKan ® release
|
||||
|
||||
Changing a password signs the account out in every other browser,
|
||||
and now a test proves it in two real browsers.
|
||||
A card moved to another board, restored from the archive or given
|
||||
a number no longer takes a **card number** another card on that board has.
|
||||
|
||||
[All changes in v12.75](CHANGELOG-big.md#v1275-2026-10-30-wekan--release)
|
||||
[All changes in v12.82](CHANGELOG-big.md#v1282-2026-11-02-wekan--release)
|
||||
|
||||
# v12.74 2026-10-30 WeKan ® release
|
||||
# v12.81 2026-11-02 WeKan ® release
|
||||
|
||||
**Caddy** and **FerretDB**, the Go servers WeKan ships, no longer
|
||||
open MPTCP listeners, which Linux kernel bug **CVE-2026-98432** is reachable
|
||||
through. The real fix is a patched host kernel.
|
||||
The **database health** probe reports a finding once, not once
|
||||
per server.
|
||||
|
||||
[All changes in v12.74](CHANGELOG-big.md#v1274-2026-10-30-wekan--release)
|
||||
[All changes in v12.81](CHANGELOG-big.md#v1281-2026-11-02-wekan--release)
|
||||
+1
-1
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.78
|
||||
ARG VERSION=12.85
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.78.0"
|
||||
appVersion: "v12.85.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
# TODO:
|
||||
# addcustomfieldtoboard: There is error: Settings must be object. So adding does not work yet.
|
||||
|
||||
try:
|
||||
# python 3
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
# python 2
|
||||
from urllib import urlencode
|
||||
|
||||
import json
|
||||
import requests
|
||||
import sys
|
||||
@@ -163,7 +156,7 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
|
||||
if arguments == 0:
|
||||
print(syntax)
|
||||
exit
|
||||
sys.exit()
|
||||
|
||||
# TODO:
|
||||
# print(" python3 api.py attachmentjson BOARDID ATTACHMENTID # One attachment as JSON base64")
|
||||
@@ -329,7 +322,7 @@ if arguments == 8:
|
||||
if new_card_id:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + new_card_id
|
||||
put_data = {'labelIds': labelIds}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -372,7 +365,7 @@ if arguments == 6:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'title': '{}'.format(newcardtitle), 'description': '{}'.format(newcarddescription)}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}',"}\n")
|
||||
@@ -411,7 +404,7 @@ if arguments == 5:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'labelIds': labelIds}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== ADD LABEL ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}',"}\n")
|
||||
@@ -437,7 +430,7 @@ if arguments == 5:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'color': '{}'.format(newcolor)}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD COLOR ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -456,7 +449,7 @@ if arguments >= 4:
|
||||
print(edlist)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'title': '{}'.format(newlisttitle)}
|
||||
body = requests.put(edlist, data=put_data, headers=headers)
|
||||
requests.put(edlist, data=put_data, headers=headers)
|
||||
print("=== EDIT LIST ===\n")
|
||||
body = requests.get(edlist, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -482,7 +475,7 @@ if arguments >= 4:
|
||||
print(edlist)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'color': '{}'.format(newcolor)}
|
||||
body = requests.put(edlist, data=put_data, headers=headers)
|
||||
requests.put(edlist, data=put_data, headers=headers)
|
||||
print("=== EDIT LIST COLOR ===\n")
|
||||
body = requests.get(edlist, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -1050,7 +1043,7 @@ if arguments >= 1:
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
|
||||
print("Storage backends: fs, gridfs, s3")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
@@ -1064,7 +1057,7 @@ if arguments >= 1:
|
||||
import os, mimetypes
|
||||
if not os.path.isfile(filepath):
|
||||
print(f"Error: File '{filepath}' not found")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
filename = os.path.basename(filepath)
|
||||
file_type = mimetypes.guess_type(filepath)[0] or 'application/octet-stream'
|
||||
params = {'boardId': boardid, 'swimlaneId': swimlaneid, 'listId': listid, 'cardId': cardid,
|
||||
@@ -1092,7 +1085,7 @@ if arguments >= 1:
|
||||
# ------- DOWNLOAD ATTACHMENT START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
@@ -1121,7 +1114,7 @@ if arguments >= 1:
|
||||
# ------- ATTACHMENT INFO START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py attachmentinfo ATTACHMENTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
|
||||
@@ -1160,7 +1153,7 @@ if arguments >= 1:
|
||||
# ------- LIST CARD ATTACHMENTS START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
@@ -1199,7 +1192,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE ATTACHMENT START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
targetboardid = sys.argv[3]
|
||||
@@ -1210,7 +1203,7 @@ if arguments >= 1:
|
||||
|
||||
if operation not in ['copy', 'move']:
|
||||
print("Operation must be 'copy' or 'move'")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
# Prepare request data
|
||||
request_data = {
|
||||
@@ -1253,7 +1246,7 @@ if arguments >= 1:
|
||||
# ------- DELETE ATTACHMENT START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py deleteattachment ATTACHMENTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
|
||||
@@ -1322,7 +1315,7 @@ if arguments >= 1:
|
||||
# ------- ADD BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboardmember BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4] if arguments > 3 else 'normal'
|
||||
@@ -1339,7 +1332,7 @@ if arguments >= 1:
|
||||
# ------- REMOVE BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboardmember BOARDID USERID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
remove_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'remove'
|
||||
@@ -1354,7 +1347,7 @@ if arguments >= 1:
|
||||
# ------- SET BOARD MEMBER ROLE START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setboardmemberrole BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4]
|
||||
@@ -1370,7 +1363,7 @@ if arguments >= 1:
|
||||
# ------- LIST BOARD DOMAINS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py boarddomains BOARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domains_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1383,7 +1376,7 @@ if arguments >= 1:
|
||||
# ------- ADD BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
@@ -1398,7 +1391,7 @@ if arguments >= 1:
|
||||
# ------- REMOVE BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
|
||||
@@ -1413,7 +1406,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID IDS".format(sys.argv[1]))
|
||||
print("IDS = comma-separated userIds, or '' to clear")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1435,7 +1428,7 @@ if arguments >= 1:
|
||||
print("Usage: python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE DATEVALUE")
|
||||
print("DATETYPE: received, start, due, end")
|
||||
print("DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1453,7 +1446,7 @@ if arguments >= 1:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {datemap[datetype]: datevalue}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== SET CARD DATE {} ===\n".format(datetype.upper()))
|
||||
print(body.text)
|
||||
# ------- SET CARD DATE END -----------
|
||||
@@ -1463,7 +1456,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS")
|
||||
print("LABELIDS = comma-separated labelIds, or '' to clear")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1483,7 +1476,7 @@ if arguments >= 1:
|
||||
# ------- MOVE CARD START -----------
|
||||
if arguments < 7:
|
||||
print("Usage: python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1497,7 +1490,7 @@ if arguments >= 1:
|
||||
'newSwimlaneId': newswimlaneid,
|
||||
'newListId': newlistid,
|
||||
}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== MOVE CARD ===\n")
|
||||
print(body.text)
|
||||
# ------- MOVE CARD END -----------
|
||||
@@ -1508,7 +1501,7 @@ if arguments >= 1:
|
||||
print("Usage: python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE")
|
||||
print("CARDSJSONFILE = JSON file with a list of titles, e.g. [\"Card A\", \"Card B\"],")
|
||||
print(" or a list of card objects [{\"title\": \"Card A\", \"description\": \"...\"}, ...]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
authorid = sys.argv[4]
|
||||
@@ -1516,16 +1509,16 @@ if arguments >= 1:
|
||||
cardsjsonfile = sys.argv[6] if arguments > 5 else None
|
||||
if not cardsjsonfile:
|
||||
print("CARDSJSONFILE is required")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(cardsjsonfile, 'r') as f:
|
||||
raw_cards = json.load(f)
|
||||
except Exception as e:
|
||||
print("Error reading CARDSJSONFILE: {}".format(e))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
if not isinstance(raw_cards, list) or len(raw_cards) == 0:
|
||||
print("CARDSJSONFILE must contain a non-empty JSON list")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
cards = []
|
||||
for item in raw_cards:
|
||||
if isinstance(item, str):
|
||||
@@ -1534,7 +1527,7 @@ if arguments >= 1:
|
||||
cards.append(item)
|
||||
else:
|
||||
print("Each card must be a string (title) or an object")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
bulk_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + 'bulk'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'cards': cards}
|
||||
@@ -1548,7 +1541,7 @@ if arguments >= 1:
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py bulkdeletecards BOARDID CARDIDS")
|
||||
print("CARDIDS = comma-separated cardIds")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids_raw = sys.argv[3]
|
||||
cardids = [x for x in cardids_raw.split(',') if x]
|
||||
@@ -1565,7 +1558,7 @@ if arguments >= 1:
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS")
|
||||
print("CARDIDS / ADDLABELIDS / REMOVELABELIDS = comma-separated, or '' for none")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids = [x for x in sys.argv[3].split(',') if x]
|
||||
add_labelids = [x for x in sys.argv[4].split(',') if x]
|
||||
@@ -1582,7 +1575,7 @@ if arguments >= 1:
|
||||
# ------- CREATE LINKED CARD START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
swimlaneid = sys.argv[4]
|
||||
@@ -1600,7 +1593,7 @@ if arguments >= 1:
|
||||
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID USERID".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1620,14 +1613,14 @@ if arguments >= 1:
|
||||
# ------- COPY CARD START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
toswimlaneid = sys.argv[5] if arguments > 4 else None
|
||||
if not toswimlaneid:
|
||||
print("TOSWIMLANEID is required")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
toboardid = sys.argv[6] if arguments > 5 else boardid
|
||||
tolistid = sys.argv[7] if arguments > 6 else listid
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + 'copy'
|
||||
@@ -1644,7 +1637,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE SWIMLANE START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID SWIMLANEID [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
toboardid = sys.argv[4] if arguments > 3 else boardid
|
||||
@@ -1663,7 +1656,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE LIST START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
toswimlaneid = sys.argv[4] if arguments > 3 else None
|
||||
@@ -1702,7 +1695,7 @@ if arguments >= 1:
|
||||
# ------- GET CARD SETTINGS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py getcardsettings BOARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'cardSettings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1717,7 +1710,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardsetting BOARDID KEY VALUE")
|
||||
print("e.g. python3 api.py setcardsetting BOARDID allowsDueDate true")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
key = sys.argv[3]
|
||||
value = sys.argv[4]
|
||||
@@ -1945,7 +1938,7 @@ if arguments >= 3 and sys.argv[1] == 'uploadbackground':
|
||||
response = requests.post(url, headers=headers, params=params, data=f)
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
|
||||
|
||||
@@ -62,7 +62,10 @@ try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
|
||||
navigator.serviceWorker.register(swPath);
|
||||
navigator.serviceWorker.register(swPath).catch(error => {
|
||||
// No service worker (a private window, an old browser): the app works without the offline cache.
|
||||
if (process.env.DEBUG === 'true') console.warn('service worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
height: 36px;
|
||||
margin-block: 4px 6px;
|
||||
margin-inline: 0 4px;
|
||||
|
||||
@@ -15,7 +15,7 @@ template(name="commentForm")
|
||||
a.js-cancel-reply(title="{{_ 'close' }}")
|
||||
i.fa.fa-times-thin
|
||||
form.js-new-comment-form
|
||||
+editor(class="js-new-comment-input")
|
||||
+editor(class="js-new-comment-input" labelKey="comment")
|
||||
| {{getUnsavedValue 'cardComment' currentCard._id}}
|
||||
.add-controls
|
||||
button.primary.confirm.clear.js-add-comment(type="submit") {{_ 'comment'}}
|
||||
@@ -40,7 +40,7 @@ template(name="comment")
|
||||
= parentComment.text
|
||||
|
||||
+inlinedForm(classNames='js-edit-comment')
|
||||
+editor(autofocus=true)
|
||||
+editor(autofocus=true labelKey="comment")
|
||||
= text
|
||||
.edit-controls
|
||||
button.primary(type="submit" title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
}
|
||||
|
||||
.board-color-belize {
|
||||
--board-theme-accent: #2980b9;
|
||||
/* The default theme. The earlier blue #2980b9 gave the header's white text a ratio of 4.30 and
|
||||
its #f2f2f2 button labels 3.84, below the 4.5 WCAG 2.1 AA (1.4.3) asks of
|
||||
small text; this blue keeps the hue and gives 5.14 and 4.59. */
|
||||
--board-theme-accent: #2573a7;
|
||||
--board-theme-dark: #216694;
|
||||
--board-theme-hover: #2e90d0;
|
||||
--board-theme-emphasis: #b96229;
|
||||
@@ -47,7 +50,7 @@
|
||||
--board-theme-selected-hover-bg: #f8fbfd;
|
||||
--board-theme-bright: #459cd6;
|
||||
--board-theme-canvas-scrollbar: #2573a7f2;
|
||||
--board-theme-sidebar-scrollbar: #2980b9f2;
|
||||
--board-theme-sidebar-scrollbar: #2573a7f2;
|
||||
}
|
||||
|
||||
.board-color-wisteria {
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
crossed it - which is the bug that had to be fixed in the Admin Panel's. */
|
||||
.boards-left-menu .menu-item.active a,
|
||||
.boards-left-menu .menu-item.active a:hover {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
/* The Admin Panel puts the selected background on the LI, not the anchor, so
|
||||
the fill reaches the row's full width. Here the anchor is what fills, so it
|
||||
@@ -215,7 +215,7 @@
|
||||
docs/Features/Pages/Starred.md */
|
||||
.board-list .board-list-item-bookmark .board-list-item {
|
||||
border: 4px solid #fff;
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
color: #fff;
|
||||
/* Room at the end for the unstar button, which sits over the tile. */
|
||||
padding-inline-end: 34px;
|
||||
@@ -481,7 +481,7 @@
|
||||
content: '';
|
||||
display: block;
|
||||
height: 30px;
|
||||
border: 2px dashed var(--theme-accent, #2980b9);
|
||||
border: 2px dashed var(--theme-accent, #2573a7);
|
||||
border-radius: 4px;
|
||||
background: rgba(41, 128, 185, 0.08);
|
||||
margin: 2px 0;
|
||||
@@ -694,7 +694,7 @@
|
||||
as the pointer crossed it. */
|
||||
.workspace-node.active > .workspace-node-content .js-select-space,
|
||||
.workspace-node.active > .workspace-node-content:hover .js-select-space {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
.workspace-node.active > .workspace-node-content .js-select-space,
|
||||
.workspace-node.active > .workspace-node-content .js-select-space i {
|
||||
@@ -1046,7 +1046,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #999; /* Darker background for better text contrast */
|
||||
background-color: #767676; /* Grey with white text: 4.54, above the 4.5 of WCAG 1.4.3 (#999 gave 2.84) */
|
||||
border-radius: 3px;
|
||||
/* 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
|
||||
@@ -1661,7 +1661,7 @@
|
||||
|
||||
.js-board {
|
||||
display: block;
|
||||
background-color: #999; /* Default gray background if no color class is applied */
|
||||
background-color: #767676; /* Default gray background if no color class is applied */
|
||||
border-radius: 3px; /* Rounded corners for board items */
|
||||
overflow: hidden; /* Ensure children respect rounded corners */
|
||||
margin: 0; /* grid gap spaces the tiles now (#6523) */
|
||||
@@ -1724,7 +1724,7 @@
|
||||
chose, not a colour, and painting over it would be losing something rather
|
||||
than calming it down. */
|
||||
body.has-theme-board-tiles .board-list > li.js-board {
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
|
||||
}
|
||||
body.has-theme-board-tiles .board-list > li.js-board .board-list-item:not(.has-background-image) {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
* so the row keeps the table's white background: with the light text below that
|
||||
* is white on white, and only the row's emoji was visible. The All Boards tile
|
||||
* has the same problem and solves it the same way — `.board-list-item` falls
|
||||
* back to a grey (#999) so its white text always has something to sit on. */
|
||||
* back to a grey (#767676) so its white text always has something to sit on. */
|
||||
.public-board-row {
|
||||
background-color: #999;
|
||||
background-color: #767676;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -338,8 +338,8 @@ function openPrevAttachment() {
|
||||
|
||||
function processTouch(){
|
||||
|
||||
xDist = touchEndCoords.x - touchStartCoords.x;
|
||||
yDist = touchEndCoords.y - touchStartCoords.y;
|
||||
const xDist = touchEndCoords.x - touchStartCoords.x;
|
||||
const yDist = touchEndCoords.y - touchStartCoords.y;
|
||||
|
||||
// Left swipe
|
||||
if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-settings-pane-collapse:hover,
|
||||
.card-settings-pane-collapse:focus-visible { color: #2980b9; }
|
||||
.card-settings-pane-collapse:focus-visible { color: #2573a7; }
|
||||
.card-settings-pane-title {
|
||||
margin: 0;
|
||||
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
|
||||
@@ -151,7 +151,7 @@ body.card-settings-splitting-active {
|
||||
.card-settings-inline-collapse:hover,
|
||||
.card-settings-inline-collapse:focus-visible,
|
||||
.card-settings-inline-menu:hover,
|
||||
.card-settings-inline-menu:focus-visible { color: #2980b9; }
|
||||
.card-settings-inline-menu:focus-visible { color: #2573a7; }
|
||||
/* The section's own checkbox, left of its title: the rows' look. A section
|
||||
hidden whole on this side keeps its rows' ticks, drawn faded. */
|
||||
.card-settings-section-side { flex: 0 0 auto; }
|
||||
@@ -175,7 +175,7 @@ body.card-settings-splitting-active {
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-settings-add-custom-field-button:hover,
|
||||
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2980b9; }
|
||||
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2573a7; }
|
||||
/* Collapsed: the items hidden, not removed, so their drag stays bound. */
|
||||
.card-settings-inline-section.is-collapsed .card-settings-inline-items,
|
||||
.card-settings-inline-section.is-collapsed .card-settings-add-custom-field { display: none; }
|
||||
@@ -232,7 +232,7 @@ body.card-settings-splitting-active {
|
||||
.card-settings-inline-menu.is-open,
|
||||
.card-settings-add-custom-field-button.is-open {
|
||||
background: rgba(41, 128, 185, 0.15);
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.card-settings-inline-panel {
|
||||
@@ -274,7 +274,7 @@ body.card-settings-splitting-active {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.card-settings-inline-panel-close:hover,
|
||||
.card-settings-inline-panel-close:focus-visible { color: #2980b9; }
|
||||
.card-settings-inline-panel-close:focus-visible { color: #2573a7; }
|
||||
.card-settings-section-action-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
width: 92%;
|
||||
|
||||
@@ -3,5 +3,5 @@ template(name="descriptionForm")
|
||||
.new-description.js-new-description(
|
||||
class="{{#if descriptionFormIsOpen}}is-open{{/if}}")
|
||||
form.js-new-description-form
|
||||
+editor(class="js-new-description-input" autofocus="autofocus")
|
||||
+editor(class="js-new-description-input" autofocus="autofocus" labelKey="description")
|
||||
| {{getUnsavedValue 'cardDescription' _id getDescription}}
|
||||
@@ -1096,7 +1096,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
@@ -1212,8 +1212,8 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
and Activities - has one rule above it and a caret in its heading that
|
||||
collapses it. The rule comes from the same template as the caret, so eleven
|
||||
sections cannot end up with ten separators.
|
||||
The heading is a button: it says so to a screen reader (role/tabindex in the
|
||||
template) and it looks like one to a mouse. */
|
||||
The heading holds a real button (cardSectionHeader in the template) that a
|
||||
screen reader and the keyboard reach, and it looks like one to a mouse. */
|
||||
/* The GROUP is the full-width row of the card, and the fields inside it share
|
||||
rows within it - which is what puts Assignee and Creator beside Members
|
||||
rather than under it. The class that made an individual field full width is
|
||||
@@ -1248,11 +1248,32 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
changes length, so it gets a fixed box. `text-align: center` rather than a
|
||||
side, because the glyph itself differs between the open and closed states
|
||||
(down, and right or left) and a fixed side would make it jump. */
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section > .fa:first-child {
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section .card-details-section-toggle > .fa:first-child {
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* The button inside the heading looks like the heading: no button chrome, the
|
||||
heading's own size, weight and colour, the caret, icon and label in a row. */
|
||||
.card-details h3.card-details-item-title .card-details-section-toggle {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
width: auto;
|
||||
}
|
||||
.card-details h3.card-details-item-title .card-details-section-toggle:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -1055,15 +1055,22 @@ template(name="cardSectionHeader")
|
||||
sits under a rule of the layout's own.
|
||||
unless noRule
|
||||
hr.card-details-section-rule
|
||||
h3.card-details-item-title.js-toggle-card-section(data-section="{{section}}" role="button" tabindex="0")
|
||||
i.fa(class="{{sectionCaret section}}")
|
||||
if icon
|
||||
i.fa(class="{{icon}}")
|
||||
| {{_ label}}
|
||||
//- An optional count after the name - "Attachments (3)" - for a section
|
||||
whose size is worth knowing before it is opened.
|
||||
if count
|
||||
| ({{count}})
|
||||
//- A real heading with a real button in it (the WAI disclosure pattern): the h3
|
||||
keeps the outline, the button is what the keyboard and a screen reader reach
|
||||
and says whether the section is open. The section menu and the layout toggle
|
||||
are beside the button, not inside it - a button must not contain controls
|
||||
(WCAG 4.1.2; axe nested-interactive and aria-allowed-role). A click anywhere
|
||||
on the heading still folds the section: the handler is on the h3.
|
||||
h3.card-details-item-title.js-toggle-card-section(data-section="{{section}}")
|
||||
button.card-details-section-toggle(type="button" aria-expanded="{{#if isSectionOpen section}}true{{else}}false{{/if}}")
|
||||
i.fa(class="{{sectionCaret section}}")
|
||||
if icon
|
||||
i.fa(class="{{icon}}")
|
||||
| {{_ label}}
|
||||
//- An optional count after the name - "Attachments (3)" - for a section
|
||||
whose size is worth knowing before it is opened.
|
||||
if count
|
||||
| ({{count}})
|
||||
//- Custom Fields puts its layout selector on the heading row, between the
|
||||
title and hamburger. A collapsed section hides actions for content that
|
||||
is not visible, so the selector only exists while this section is open.
|
||||
|
||||
@@ -38,26 +38,7 @@ import {
|
||||
datePickerRendered,
|
||||
datePickerHelpers,
|
||||
} from '/client/lib/datepicker';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
formatTime,
|
||||
getISOWeek,
|
||||
isValidDate,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isSame,
|
||||
add,
|
||||
subtract,
|
||||
startOf,
|
||||
endOf,
|
||||
format,
|
||||
parseDate,
|
||||
now,
|
||||
createDate,
|
||||
fromNow,
|
||||
calendar
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { formatDateTime, add, now } from '/imports/lib/dateUtils';
|
||||
import Cards from '/models/cards';
|
||||
import Boards from '/models/boards';
|
||||
import Checklists from '/models/checklists';
|
||||
@@ -1209,16 +1190,6 @@ Template.cardSectionHeader.events({
|
||||
if (!section) return;
|
||||
cardSectionState.set(section, !isCardSectionOpen(section));
|
||||
},
|
||||
// A heading that can be operated with the mouse can be operated with the
|
||||
// keyboard: it carries role="button" and tabindex, so Enter and Space are
|
||||
// what a screen reader user presses.
|
||||
'keydown .js-toggle-card-section'(event) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
const section = event.currentTarget.dataset.section;
|
||||
if (section) cardSectionState.set(section, !isCardSectionOpen(section));
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDetails.events({
|
||||
@@ -2396,7 +2367,7 @@ const filterMembers = (filterTerm) => {
|
||||
.map(({ member }) => member);
|
||||
}
|
||||
return members;
|
||||
}
|
||||
};
|
||||
|
||||
Template.editCardRequesterForm.onRendered(function () {
|
||||
autosize(this.$('.js-edit-card-requester'));
|
||||
|
||||
@@ -9,5 +9,5 @@ template(name="cardPomodoro")
|
||||
button.js-stop-pomodoro.pomodoro-btn.negate(type="button") {{_ 'stop'}}
|
||||
else
|
||||
if canControlPomodoro
|
||||
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}")
|
||||
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}" aria-label="{{_ 'pomodoro-work'}} ({{_ 'minutes'}})")
|
||||
button.js-start-pomodoro.pomodoro-btn.primary(type="button") {{_ 'pomodoro-start'}}
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-checklist {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
}
|
||||
textarea.js-add-checklist-item,
|
||||
textarea.js-edit-checklist-item {
|
||||
@@ -11,7 +11,7 @@ textarea.js-edit-checklist-item {
|
||||
.delete-text,
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
float: inline-end;
|
||||
@@ -139,7 +139,7 @@ textarea.js-edit-checklist-item {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.checklist-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -167,11 +167,11 @@ textarea.js-edit-checklist-item {
|
||||
.checklist-item .checklist-item-due-date-add {
|
||||
align-self: center;
|
||||
margin-inline-start: 6px;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
text-decoration: none;
|
||||
}
|
||||
.checklist-item .checklist-item-due-date-add:hover {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
@@ -235,7 +235,7 @@ textarea.js-edit-checklist-item {
|
||||
|
||||
.checklist-title .checklist-collapse:hover,
|
||||
.checklist-title .checklist-collapse:focus-visible {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
|
||||
.checklist-title .checklist-due-date-add {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
}
|
||||
.ui-font-btn.active,
|
||||
.ui-font-size-btn.active {
|
||||
background: #2980b9;
|
||||
border-color: #2980b9;
|
||||
background: #2573a7;
|
||||
border-color: #2573a7;
|
||||
color: #fff;
|
||||
}
|
||||
/* The "Unset" colour reset links look like buttons. */
|
||||
@@ -155,6 +155,7 @@
|
||||
}
|
||||
.card-label-green {
|
||||
background-color: #3cb500;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-green:hover {
|
||||
color: #000;
|
||||
@@ -169,6 +170,7 @@
|
||||
}
|
||||
.card-label-red {
|
||||
background-color: #eb4646;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-purple {
|
||||
background-color: #a632db;
|
||||
@@ -182,6 +184,7 @@
|
||||
}
|
||||
.card-label-sky {
|
||||
background-color: #00c2e0;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-black {
|
||||
background-color: #4d4d4d;
|
||||
@@ -213,6 +216,7 @@
|
||||
}
|
||||
.card-label-magenta {
|
||||
background-color: #f0f;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-gold {
|
||||
background-color: #ffd700;
|
||||
@@ -223,6 +227,7 @@
|
||||
}
|
||||
.card-label-gray {
|
||||
background-color: #808080;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-saddlebrown {
|
||||
background-color: #8b4513;
|
||||
|
||||
@@ -1011,7 +1011,7 @@
|
||||
.minicard-list-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1023,7 +1023,7 @@
|
||||
.minicard-swimlane-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-subtask {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
}
|
||||
textarea.js-add-subtask-item,
|
||||
textarea.js-edit-subtask-item {
|
||||
@@ -12,7 +12,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtask-title .js-delete-subtask,
|
||||
.subtask-title .js-view-subtask,
|
||||
.js-delete-subtask-item {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
flex-shrink: 0;
|
||||
@@ -163,7 +163,7 @@ textarea.js-edit-subtask-item {
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.subtasks-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-style: italic;
|
||||
}
|
||||
.subtasks-item .item-title .viewer p {
|
||||
@@ -186,7 +186,7 @@ textarea.js-edit-subtask-item {
|
||||
instead of disappearing — the same dimmed/italic treatment a checked
|
||||
checklist item gets (.subtasks-item .item-title.is-checked above). */
|
||||
.subtask.is-completed .subtask-title .title {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-hide-completed-toggle {
|
||||
display: inline-block;
|
||||
margin: 0.2em 0 0.5em 1.33em;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
cursor: pointer;
|
||||
}
|
||||
.subtasks-hide-completed-toggle:hover {
|
||||
@@ -224,7 +224,7 @@ textarea.js-edit-subtask-item {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.5em;
|
||||
padding-top: 6px;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -251,5 +251,5 @@ textarea.js-edit-subtask-item {
|
||||
}
|
||||
.subtask-title.is-done .title {
|
||||
text-decoration: line-through;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
}
|
||||
@@ -107,6 +107,24 @@
|
||||
white-space: nowrap;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
.selected-calendar-heading .selected-calendar-date {
|
||||
margin-inline-start: 4px;
|
||||
/* #6853: year, month and day drop-downs under the calendar. */
|
||||
.selected-calendar-selects {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
.selected-calendar-selects select,
|
||||
.selected-calendar-selects input {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
.selected-calendar-selects .js-calendar-year { flex: 1.4; }
|
||||
.calendar-date-input .selected-calendar-selects select:focus-visible,
|
||||
.calendar-date-input .selected-calendar-selects input:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -20,8 +20,6 @@ template(name="calendarDateInput")
|
||||
button.primary.js-calendar-previous(type="button" aria-label="{{_ 'previous'}} {{_ 'month'}}") ‹
|
||||
span(aria-live="polite")
|
||||
| {{monthLabel}}
|
||||
if inline
|
||||
span.selected-calendar-date {{headingDateText}}
|
||||
//- #6838: back to today's month from wherever the arrows went. It only
|
||||
//- shows the month; the date stays as it is until a day is clicked.
|
||||
button.primary.js-calendar-today(type="button" aria-label="{{_ 'today'}}") {{_ 'today'}}
|
||||
@@ -39,3 +37,14 @@ template(name="calendarDateInput")
|
||||
td(class="{{#if this.today}}is-today-cell{{/if}}")
|
||||
unless this.empty
|
||||
button.primary.js-calendar-day(type="button" class="{{#if today}}is-today{{/if}}" data-date=iso title=title aria-label=title aria-pressed=selected aria-current=ariaCurrent tabindex=tabIndex) {{label}}
|
||||
//- #6853: any year, month and day by a pick, under the calendar.
|
||||
.selected-calendar-selects
|
||||
input.js-calendar-year(type="number" step="1" inputmode="numeric" value=yearValue aria-label="{{_ 'predicate-year'}}")
|
||||
select.js-calendar-month(aria-label="{{_ 'month'}}")
|
||||
each monthOptions
|
||||
option(value=value selected=selected) {{label}}
|
||||
select.js-calendar-day-select(aria-label="{{_ 'day'}}")
|
||||
if noDaySelected
|
||||
option(value="" selected disabled) –
|
||||
each dayOptions
|
||||
option(value=value selected=selected) {{label}}
|
||||
@@ -6,7 +6,10 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { formatDate } from '/imports/lib/dateUtils';
|
||||
import { formatDateForDisplay, dateDisplayPreferences } from '/client/lib/dateDisplay';
|
||||
const { CALENDAR_SYSTEMS, nativeCalendarParts } = require('/imports/lib/calendarSystems');
|
||||
const { calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear } = require('/imports/lib/calendarMonth');
|
||||
const {
|
||||
calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
|
||||
calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInMonth,
|
||||
} = require('/imports/lib/calendarMonth');
|
||||
|
||||
function fromInput(value) {
|
||||
if (!value) return null;
|
||||
@@ -53,9 +56,45 @@ Template.calendarDateInput.helpers({
|
||||
useNativeInput() { return !this.inline && dateDisplayPreferences().calendarSystem === 'gregorian'; },
|
||||
inputType() { return this.withTime ? 'datetime-local' : 'date'; },
|
||||
selectedDateText() { return formatDateForDisplay(Template.instance().selectedDate.get(), false); },
|
||||
headingDateText() {
|
||||
// #6853: the year, month and day drop-downs under the calendar. Each option
|
||||
// carries a date as its value, found in the selected calendar system, so any
|
||||
// past, present or future year, month and day is one pick away.
|
||||
// The year as a number any year can be typed or stepped to, also one before
|
||||
// the era (0 is 1 BC) or thousands of years away.
|
||||
yearValue() { return calendarSignedYear(Template.instance().shownDate.get(), selectedSystem().intl); },
|
||||
monthOptions() {
|
||||
const tpl = Template.instance();
|
||||
return formatDateForDisplay(tpl.selectedDate.get() || tpl.shownDate.get(), false);
|
||||
const calendar = selectedSystem().intl;
|
||||
const language = TAPi18n.getLanguage() || 'en';
|
||||
return calendarYearMonths(tpl.shownDate.get(), calendar).map(month => {
|
||||
let label;
|
||||
try {
|
||||
label = new Intl.DateTimeFormat(language, { calendar, month: 'long', numberingSystem: 'latn' }).format(month.date);
|
||||
} catch (error) { label = formatDateForDisplay(month.date, false); }
|
||||
return { _id: formatDate(month.date), value: formatDate(month.date), label, selected: month.selected };
|
||||
});
|
||||
},
|
||||
dayOptions() {
|
||||
const tpl = Template.instance();
|
||||
const calendar = selectedSystem().intl;
|
||||
const { start, end } = calendarMonthRange(tpl.shownDate.get(), calendar);
|
||||
const selected = tpl.selectedDate.get();
|
||||
const selectedIso = selected ? formatDate(selected) : '';
|
||||
const days = [];
|
||||
for (let day = shiftedDay(start, 0); day < end; day = shiftedDay(day, 1)) {
|
||||
days.push({
|
||||
_id: formatDate(day), value: formatDate(day),
|
||||
label: nativeCalendarParts(day, calendar).day, selected: formatDate(day) === selectedIso,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
},
|
||||
noDaySelected() {
|
||||
const tpl = Template.instance();
|
||||
const selected = tpl.selectedDate.get();
|
||||
if (!selected) return true;
|
||||
const { start, end } = calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl);
|
||||
return selected < start || selected >= end;
|
||||
},
|
||||
expanded() { return !!Template.instance().data.inline || Template.instance().expanded.get(); },
|
||||
calendarName() { return TAPi18n.__(selectedSystem().labelKey); },
|
||||
@@ -63,8 +102,11 @@ Template.calendarDateInput.helpers({
|
||||
const date = Template.instance().shownDate.get();
|
||||
try {
|
||||
return new Intl.DateTimeFormat(TAPi18n.getLanguage() || 'en', {
|
||||
calendar: selectedSystem().intl, month: 'long',
|
||||
...(Template.instance().data.inline ? {} : { year: 'numeric' }), numberingSystem: 'latn',
|
||||
// #6853: the year is always shown, also in the popup: month and year
|
||||
// above the calendar, never the YYYY-MM-DD of the selected day.
|
||||
calendar: selectedSystem().intl, month: 'long', year: 'numeric', numberingSystem: 'latn',
|
||||
// A year before the era needs its era, or 3000 BC reads as 3000.
|
||||
...(calendarSignedYear(date, selectedSystem().intl) <= 0 ? { era: 'short' } : {}),
|
||||
}).format(date);
|
||||
} catch (error) { return formatDateForDisplay(date, false); }
|
||||
},
|
||||
@@ -136,6 +178,33 @@ Template.calendarDateInput.events({
|
||||
},
|
||||
'click .js-calendar-year-previous'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), -1, selectedSystem().intl)); },
|
||||
'click .js-calendar-year-next'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), 1, selectedSystem().intl)); },
|
||||
// #6853: a pick in a drop-down selects that date and shows its month.
|
||||
'change .js-calendar-year'(event, tpl) {
|
||||
const input = event.currentTarget;
|
||||
const target = dateInCalendarYear(tpl.shownDate.get(), Number(input.value), selectedSystem().intl);
|
||||
if (!target || input.value.trim() === '') {
|
||||
// A year that is not a whole number, or beyond what a date can hold:
|
||||
// the field goes back to the year being shown.
|
||||
input.value = calendarSignedYear(tpl.shownDate.get(), selectedSystem().intl);
|
||||
return;
|
||||
}
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
// Enter in the year field applies the year, not the popup's Save.
|
||||
'keydown .js-calendar-year'(event) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.currentTarget.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
'change .js-calendar-month'(event, tpl) {
|
||||
const target = sameDayInMonth(tpl.selectedDate.get() || tpl.shownDate.get(), fromInput(event.currentTarget.value), selectedSystem().intl);
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
'change .js-calendar-day-select'(event, tpl) {
|
||||
const target = fromInput(event.currentTarget.value);
|
||||
if (!target) return;
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
'click .js-calendar-day'(event, tpl) {
|
||||
event.preventDefault();
|
||||
writeValue(tpl, fromInput(event.currentTarget.dataset.date));
|
||||
|
||||
@@ -470,7 +470,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-composer .open-list-composer,
|
||||
.list .list-composer .open-list-composer {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
}
|
||||
.list.list-composer .list-name-input,
|
||||
.list .list-composer .list-name-input {
|
||||
@@ -534,7 +534,7 @@ form.inlined-form .list-composer {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-weight: bold;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
vertical-align: middle;
|
||||
@@ -722,7 +722,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
|
||||
.list-header .cardCount {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -781,7 +781,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-body .open-minicard-composer {
|
||||
border-radius: 2px;
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
display: block;
|
||||
/* Smaller than a card title, because it is not a card: it is the affordance
|
||||
for making one. At the inherited 16px it was the largest text in the list
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
selected thing. The fill is the slide where the theme has one. */
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a,
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a:hover {
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
|
||||
color: #fff !important;
|
||||
}
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a i {
|
||||
|
||||
@@ -39,7 +39,7 @@ template(name="dueCards")
|
||||
else
|
||||
.global-search-results-list-wrapper
|
||||
.no-results
|
||||
h3 {{_ 'dueCards-noResults-title'}}
|
||||
h2 {{_ 'dueCards-noResults-title'}}
|
||||
p {{_ 'dueCards-noResults-description'}}
|
||||
|
||||
template(name="dueCardsViewChangePopup")
|
||||
|
||||
@@ -7,6 +7,7 @@ template(name="editor")
|
||||
dir="auto"
|
||||
class="{{class}}"
|
||||
id=id
|
||||
aria-label=labelText
|
||||
autofocus=autofocus
|
||||
placeholder="{{_ 'comment-placeholder'}}")
|
||||
+Template.contentBlock
|
||||
|
||||
@@ -45,6 +45,16 @@ function syncMarkdownUrlSchemes(setting) {
|
||||
return schemes;
|
||||
}
|
||||
|
||||
// WCAG 4.1.2: a field has a name. The including template passes the KEY of its
|
||||
// name (labelKey="comment"), and the editor translates it, as every included
|
||||
// template does (tests/jadeInclusionArgsNoMustache.test.cjs).
|
||||
Template.editor.helpers({
|
||||
labelText() {
|
||||
const data = Template.currentData() || {};
|
||||
return data.labelKey ? TAPi18n.__(data.labelKey) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const specialHandles = [
|
||||
{userId: 'board_members', username: 'board_members'},
|
||||
{userId: 'card_members', username: 'card_members'},
|
||||
|
||||
@@ -106,6 +106,8 @@ code {
|
||||
}
|
||||
.list-title {
|
||||
background-color: #a9a9a9;
|
||||
/* White on this grey was 2.35; black is 8.94 (WCAG 1.4.3). */
|
||||
color: #000;
|
||||
}
|
||||
.global-search-footer {
|
||||
border: none;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
#header {
|
||||
color: #fff;
|
||||
transition: background-color 0.4s;
|
||||
background: #2980b9;
|
||||
background: #2573a7;
|
||||
z-index: 17;
|
||||
}
|
||||
#header #header-main-bar {
|
||||
@@ -298,7 +298,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
of colour now, so the header reads as one header. The per-theme rules do
|
||||
the same, by naming this bar in the selector list the main bar already has
|
||||
(boardColors.css) rather than by restating each hex a second time. */
|
||||
background: #2980b9;
|
||||
background: #2573a7;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
display: flex;
|
||||
z-index: 1000;
|
||||
@@ -745,7 +745,7 @@ body.mobile-mode #header-quick-access .allBoards {
|
||||
color: #fff !important;
|
||||
/* The active theme, like the rest of the bar; the stock blue where no theme
|
||||
has published an accent. */
|
||||
background: var(--theme-accent, #2980b9) !important;
|
||||
background: var(--theme-accent, #2573a7) !important;
|
||||
}
|
||||
|
||||
#header-quick-access .mobile-mode-toggle .board-header-btn.mobile-active i.mobile-icon .fa,
|
||||
|
||||
@@ -44,7 +44,9 @@ template(name="header")
|
||||
that do the same job. Board admins only - everybody else gets the
|
||||
plain title. The `is-editable` class also gives the element a minimum
|
||||
width, so a board whose title is EMPTY still has an area to click.
|
||||
span.header-page-title(title="{{headerTitleFullPath}}"
|
||||
//- role="heading" aria-level="1": every page has one level-one heading, and this
|
||||
//- is the page's name (WCAG 1.3.1 / 2.4.6; axe page-has-heading-one).
|
||||
span.header-page-title(title="{{headerTitleFullPath}}" role="heading" aria-level="1"
|
||||
class="{{#if canEditBoardTitle}}js-edit-board-title is-editable{{/if}}")
|
||||
if headerTitleKey
|
||||
| {{_ headerTitleKey}}
|
||||
@@ -119,7 +121,8 @@ template(name="header")
|
||||
//- One GROUP: the two adjacent buttons are about the same thing - how
|
||||
many items you have starred, and whether this one is among them.
|
||||
.header-star-group.js-header-collapsible-icon
|
||||
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}")
|
||||
//- aria-label: WCAG 2.5.3 Label in Name - the visible text is the star count.
|
||||
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}" aria-label="{{starredBoardsCount}} {{_ 'allboards.starred'}}")
|
||||
i.fa.fa-caret-down
|
||||
span.board-star-counter {{starredBoardsCount}}
|
||||
|
||||
@@ -245,7 +248,8 @@ template(name="header")
|
||||
if appIsOffline
|
||||
+offlineWarning
|
||||
|
||||
if currentUser.isBoardMember
|
||||
//- #6854: on Home and All Boards too, not only on a board.
|
||||
if announcementForUser
|
||||
if hasAnnouncement
|
||||
.announcement(role="status" aria-live="polite")
|
||||
p
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Announcements, {
|
||||
announcementVersion,
|
||||
shouldShowAnnouncement,
|
||||
showAnnouncementToUser,
|
||||
} from '/models/announcements';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
// What this bar calls the page you are on. models/lib/pageTitles.js
|
||||
@@ -316,6 +317,14 @@ Template.header.helpers({
|
||||
return ['waiting', 'failed', 'offline'].includes(status.status);
|
||||
},
|
||||
|
||||
announcementForUser() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return showAnnouncementToUser({
|
||||
userId: user && user._id,
|
||||
board: Utils.getCurrentBoard(),
|
||||
});
|
||||
},
|
||||
|
||||
hasAnnouncement() {
|
||||
const announcement = Announcements.findOne();
|
||||
if (!announcement || !announcement.enabled) {
|
||||
|
||||
@@ -4,7 +4,8 @@ template(name="shortcutsModalTitle")
|
||||
| {{_ 'keyboard-shortcuts'}}
|
||||
|
||||
template(name="keyboardShortcuts")
|
||||
.wrapper.shortcuts-list
|
||||
//- tabindex: the list scrolls when the window is short, and a scrolling region must be reachable by keyboard (WCAG 2.1.1; axe scrollable-region-focusable).
|
||||
.wrapper.shortcuts-list(tabindex="0" role="region" aria-label="{{_ 'keyboard-shortcuts'}}")
|
||||
each mapping
|
||||
.shortcuts-list-item
|
||||
.shortcuts-list-item-keys
|
||||
|
||||
@@ -378,14 +378,14 @@ h6 {
|
||||
}
|
||||
.quiet,
|
||||
.quiet a {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
}
|
||||
.error,
|
||||
.error a {
|
||||
color: #eb3800;
|
||||
}
|
||||
.no-items-message {
|
||||
color: #666;
|
||||
color: #595959;
|
||||
margin: 36px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ template(name="userFormsLayout")
|
||||
section.auth-layout
|
||||
if logoSettingsReady
|
||||
if currentSetting.hideLogo
|
||||
h1.at-form-landing-logo
|
||||
//- A spacer, not a heading: an empty h1 is announced as an empty heading.
|
||||
div.at-form-landing-logo(aria-hidden="true")
|
||||
br
|
||||
br
|
||||
unless currentSetting.hideLogo
|
||||
h1.at-form-landing-logo
|
||||
div.at-form-landing-logo(aria-hidden="true")
|
||||
if currentSetting.customLoginLogoImageUrl
|
||||
if currentSetting.customLoginLogoLinkUrl
|
||||
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
|
||||
@@ -49,7 +50,9 @@ template(name="userFormsLayout")
|
||||
+viewer
|
||||
| {{currentSetting.textBelowCustomLoginLogo}}
|
||||
hr
|
||||
section.auth-layout
|
||||
//- role="main": the sign-in and register form is the page's main landmark
|
||||
(WCAG 1.3.1 / 2.4.1), so the title, the links and the language picker are in one.
|
||||
section.auth-layout(role="main")
|
||||
section.auth-dialog
|
||||
if isLoading
|
||||
+loader
|
||||
|
||||
@@ -672,6 +672,28 @@ Template.userFormsLayout.onDestroyed(function () {
|
||||
this.removeAuthenticationSubmit?.();
|
||||
});
|
||||
|
||||
// The form's title ("Sign In", "Create an Account") is an h3 of the useraccounts
|
||||
// package and the first heading of the page: a level-one heading, whatever the
|
||||
// package draws it as (WCAG 1.3.1; axe page-has-heading-one and heading-order).
|
||||
// The package redraws the form when the user moves between sign in and register,
|
||||
// so the title is marked again whenever the dialog changes.
|
||||
Template.userFormsLayout.onRendered(function () {
|
||||
const dialog = this.find('.auth-dialog');
|
||||
if (!dialog) return;
|
||||
const mark = () => {
|
||||
dialog.querySelectorAll('.at-title h1, .at-title h2, .at-title h3').forEach(heading => {
|
||||
if (heading.getAttribute('aria-level') !== '1') heading.setAttribute('aria-level', '1');
|
||||
});
|
||||
};
|
||||
mark();
|
||||
this.titleObserver = new MutationObserver(mark);
|
||||
this.titleObserver.observe(dialog, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(function () {
|
||||
if (this.titleObserver) this.titleObserver.disconnect();
|
||||
});
|
||||
|
||||
function getAuthenticationMethod(
|
||||
settings,
|
||||
match,
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
/* Disabled = the same themed outline, faded. Not a grey block. */
|
||||
/* A disabled pager still has to look like the THEME. It is filled with the
|
||||
theme colour, and dimming it is what takes the theme back out: at 0.4 the
|
||||
belize blue #2980b9 arrived on the page's grey as rgb(122,170,202), a pale
|
||||
belize blue #2573a7 arrived on the page's grey as rgb(122,170,202), a pale
|
||||
wash with no family resemblance to the header above it - and on a one-page
|
||||
list, where BOTH arrows are disabled, that is the whole pager.
|
||||
|
||||
|
||||
@@ -807,7 +807,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
above pins every row's text to `#000 !important`; the icon is named separately
|
||||
for the same reason. */
|
||||
.pop-over-list li > a:not(.disabled):hover {
|
||||
background-color: var(--theme-accent, #2980b9);
|
||||
background-color: var(--theme-accent, #2573a7);
|
||||
color: #fff !important;
|
||||
}
|
||||
.pop-over-list li > a:not(.disabled):hover i,
|
||||
@@ -822,7 +822,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background: #fff;
|
||||
}
|
||||
.pop-over-list li > a .sub-name {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
display: block;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
@@ -867,7 +867,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background-color: #2e85b8;
|
||||
}
|
||||
.pop-over-list li > a.disabled {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
cursor: default;
|
||||
}
|
||||
.pop-over-list li > a.disabled .vis-icon {
|
||||
@@ -878,7 +878,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
}
|
||||
.pop-over-list li > a.disabled:hover .sub-name,
|
||||
.pop-over-list li > a.disabled:hover .quiet {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
}
|
||||
.pop-over-list li > a.disabled:active {
|
||||
background: none;
|
||||
@@ -972,7 +972,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
user's own theme, the board's colour or the site theme - whichever is in
|
||||
force. The fallback is the stock WeKan blue, so an unthemed instance is
|
||||
unchanged. */
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
height: calc(48px * var(--wekan-ui-font-scale, 1));
|
||||
padding: 0px 0px;
|
||||
border: 0px;
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
// 3. the user's own override
|
||||
// See docs/Features/General/Theme/Change-Color.md for the whole design.
|
||||
|
||||
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
const DEFAULT_WHEEL = ['#2573a7', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
|
||||
function readCurrent(scope) {
|
||||
if (scope === 'board') {
|
||||
|
||||
@@ -22,7 +22,7 @@ section#notifications-drawer {
|
||||
padding-top: 36px;
|
||||
}
|
||||
section#notifications-drawer a:hover {
|
||||
color: #2980b9 !important;
|
||||
color: #2573a7 !important;
|
||||
}
|
||||
section#notifications-drawer .header {
|
||||
position: fixed;
|
||||
@@ -47,7 +47,7 @@ section#notifications-drawer .header .notification-menu-toggle {
|
||||
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu-toggle:hover {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu {
|
||||
position: absolute;
|
||||
@@ -90,7 +90,7 @@ section#notifications-drawer .header .notification-menu .menu-item .check-icon {
|
||||
min-width: 20px;
|
||||
margin-inline-end: 8px;
|
||||
text-align: center;
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
font-weight: bold;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
/* Drag-and-drop workflow editor */
|
||||
.rules-workflow .workflow-help {
|
||||
color: #727479;
|
||||
color: #5a5c61;
|
||||
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
@@ -284,7 +284,7 @@
|
||||
display: flex;
|
||||
}
|
||||
.triggers-content {
|
||||
color: #727479;
|
||||
color: #5a5c61;
|
||||
background: #dedede;
|
||||
}
|
||||
.triggers-content .triggers-body {
|
||||
@@ -437,7 +437,7 @@
|
||||
}
|
||||
.rules-import-export .rules-ie-hint {
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
color: #727479;
|
||||
color: #5a5c61;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.rules-import-export .rules-ie-textarea {
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
}
|
||||
|
||||
.migration-progress-header {
|
||||
/* WeKan blue (matches the main header #2980b9) */
|
||||
background: linear-gradient(135deg, #2980b9 0%, #1a5276 100%);
|
||||
/* WeKan blue (matches the main header #2573a7) */
|
||||
background: linear-gradient(135deg, #2573a7 0%, #1a5276 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
.migration-progress-overall-fill {
|
||||
/* WeKan blue */
|
||||
background: linear-gradient(90deg, #2980b9, #3498db);
|
||||
background: linear-gradient(90deg, #2573a7, #3498db);
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
.migration-progress-step-fill {
|
||||
/* WeKan blue */
|
||||
background: linear-gradient(90deg, #3498db, #2980b9);
|
||||
background: linear-gradient(90deg, #3498db, #2573a7);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
@@ -156,7 +156,7 @@
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-inline-start: 4px solid #2980b9;
|
||||
border-inline-start: 4px solid #2573a7;
|
||||
}
|
||||
|
||||
.migration-progress-status-label {
|
||||
|
||||
@@ -2029,7 +2029,7 @@ Template.modifyTeamsUsers.events({
|
||||
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
|
||||
// Copy, so the cached minimongo document is not mutated in place.
|
||||
userTms = (currentUser.teams || []).slice();
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
|
||||
if(currUserTeamIndex == -1){
|
||||
userTms.push({
|
||||
"teamId": selectedEltValueId,
|
||||
@@ -2048,7 +2048,7 @@ Template.modifyTeamsUsers.events({
|
||||
for(let i = 0; i < selectedUserChkBoxUserIds.length; i++){
|
||||
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
|
||||
userTms = (currentUser.teams || []).slice();
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
|
||||
if(currUserTeamIndex != -1){
|
||||
userTms.splice(currUserTeamIndex, 1);
|
||||
}
|
||||
@@ -2336,7 +2336,7 @@ Template.editUserPopup.events({
|
||||
userTms.push({
|
||||
"teamId": userTeamsIdsList[i],
|
||||
"teamDisplayName": userTeamsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2349,7 +2349,7 @@ Template.editUserPopup.events({
|
||||
userOrganizations.push({
|
||||
"orgId": userOrgsIdsList[i],
|
||||
"orgDisplayName": userOrgsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2553,7 +2553,7 @@ const UpdateUserOrgsOrTeamsElement = function(isNewUser = false){
|
||||
}
|
||||
selectedElt.value = "-1";
|
||||
selectedElt.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
Template.newOrgPopup.events({
|
||||
submit(event, templateInstance) {
|
||||
@@ -2633,7 +2633,7 @@ Template.newUserPopup.events({
|
||||
userTms.push({
|
||||
"teamId": userTeamsIdsList[i],
|
||||
"teamDisplayName": userTeamsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2645,7 +2645,7 @@ Template.newUserPopup.events({
|
||||
userOrganizations.push({
|
||||
"orgId": userOrgsIdsList[i],
|
||||
"orgDisplayName": userOrgsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
.setting-content {
|
||||
color: #727479;
|
||||
color: #5a5c61;
|
||||
background: #dedede;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -99,7 +99,7 @@
|
||||
selected entry back to white as soon as the pointer crossed it. */
|
||||
.setting-content .content-body .side-menu ul li.active,
|
||||
.setting-content .content-body .side-menu ul li.active:hover {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
|
||||
/* …and when a THEME is active, the row is painted by that theme's own #header rule
|
||||
@@ -488,7 +488,7 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
|
||||
outline: 2px solid #2980b9;
|
||||
outline: 2px solid #2573a7;
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -511,7 +511,7 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--theme-accent, #2980b9);
|
||||
outline: 2px solid var(--theme-accent, #2573a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -562,13 +562,13 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content .schedule-day-btn.active {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
border-color: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
border-color: var(--theme-accent, #2573a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-content .schedule-day-btn:focus-visible {
|
||||
outline: 2px solid var(--theme-accent, #2980b9);
|
||||
outline: 2px solid var(--theme-accent, #2573a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -3782,7 +3782,7 @@ Template.addMemberPopup.helpers({
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return board && board.hasMember(userId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Template.addBoardOrgPopup.onCreated(function() {
|
||||
initGroupPickerState(this, 'org');
|
||||
@@ -3823,7 +3823,7 @@ Template.addBoardOrgPopup.events({
|
||||
"orgId": selectedOrgId,
|
||||
"orgDisplayName": selectedOrgDisplayName,
|
||||
"isActive" : true,
|
||||
})
|
||||
});
|
||||
|
||||
if (selectedOrgId != "-1") {
|
||||
Meteor.call('setBoardOrgs', boardOrganizations, currentBoard._id);
|
||||
@@ -3994,7 +3994,7 @@ Template.addBoardTeamPopup.events({
|
||||
"teamId": selectedTeamId,
|
||||
"teamDisplayName": selectedTeamDisplayName,
|
||||
"isActive" : true,
|
||||
})
|
||||
});
|
||||
|
||||
if (selectedTeamId != "-1") {
|
||||
let members = currentBoard.members;
|
||||
@@ -4011,7 +4011,7 @@ Template.addBoardTeamPopup.events({
|
||||
let index;
|
||||
if (boardTeamUsers && boardTeamUsers.length > 0) {
|
||||
boardTeamUsers.forEach((u) => {
|
||||
index = members.findIndex(function(m){ return m.userId == u._id});
|
||||
index = members.findIndex(function(m){ return m.userId == u._id;});
|
||||
if(index == -1){
|
||||
members.push({
|
||||
"isActive": true,
|
||||
@@ -4076,7 +4076,7 @@ Template.removeBoardTeamPopup.events({
|
||||
let index;
|
||||
if (boardTeamUsers && boardTeamUsers.length > 0) {
|
||||
boardTeamUsers.forEach((u) => {
|
||||
index = members.findIndex(function(m){ return m.userId == u._id});
|
||||
index = members.findIndex(function(m){ return m.userId == u._id;});
|
||||
if(index !== -1 && !members[index].isAdmin){
|
||||
members.splice(index, 1);
|
||||
}
|
||||
|
||||
@@ -417,14 +417,14 @@ body.swimlane-resizing-active * {
|
||||
`outline`, not `border` or `box-shadow`: it is drawn outside the box and so
|
||||
moves neither the swimlane's rows nor the list's cards by a pixel. */
|
||||
.is-revealed {
|
||||
outline: 3px solid #2980b9;
|
||||
outline: 3px solid #2573a7;
|
||||
outline-offset: -3px;
|
||||
animation: wekan-reveal-fade 2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes wekan-reveal-fade {
|
||||
0% { outline-color: #2980b9; }
|
||||
70% { outline-color: #2980b9; }
|
||||
0% { outline-color: #2573a7; }
|
||||
70% { outline-color: #2573a7; }
|
||||
100% { outline-color: transparent; }
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: #8c8c8c !important;
|
||||
color: #595959 !important;
|
||||
}
|
||||
/* Outweighs .pop-over-list li > a:not(.disabled):hover, which would otherwise
|
||||
paint the delete button with the generic blue row-hover colour. */
|
||||
@@ -149,7 +149,7 @@
|
||||
background-color: #b04632;
|
||||
}
|
||||
.avatar-upload-hints {
|
||||
color: #8c8c8c;
|
||||
color: #595959;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
line-height: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
margin: 6px 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
template(name="userAvatar")
|
||||
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
|
||||
//- WCAG 2.5.3 Label in Name: the visible text of an initials avatar is its initials,
|
||||
//- so the accessible name starts with them (initialsInName). A picture has no text.
|
||||
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}" aria-label="{{initialsInName}}{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
|
||||
if userData
|
||||
if userData.profile.avatarUrl
|
||||
img.avatar.avatar-image(src="{{avatarUrl}}" alt="")
|
||||
|
||||
@@ -45,6 +45,18 @@ const AVATAR_USER_FIELDS = {
|
||||
};
|
||||
|
||||
Template.userAvatar.helpers({
|
||||
// WCAG 2.5.3 Label in Name: an avatar drawn as initials shows those letters as
|
||||
// text, so the link's accessible name has to contain them. The name is the
|
||||
// initials followed by the title, "AB Ann Brown (ann) Admin". A picture shows
|
||||
// no text, so its name stays the title.
|
||||
initialsInName() {
|
||||
const user = ReactiveCache.getUser(this.userId, { fields: AVATAR_USER_FIELDS });
|
||||
if (user && user.profile && user.profile.avatarUrl) return '';
|
||||
const defaultAvatar = Template.userAvatar.__helpers.get('defaultAvatarUrl');
|
||||
if (user && defaultAvatar && defaultAvatar.call(this)) return '';
|
||||
const initials = (user && user.getInitials && user.getInitials()) || '';
|
||||
return initials ? `${initials} ` : '';
|
||||
},
|
||||
defaultAvatarUrl() {
|
||||
if (!(Meteor.settings.public && Meteor.settings.public.defaultAvatar)) return '';
|
||||
const userId = this.userId;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
template(name="headerUserBar")
|
||||
#header-user-bar
|
||||
a.header-user-bar-name.js-open-header-member-menu(title="{{_ 'memberMenuPopup-title'}}")
|
||||
//- aria-label: WCAG 2.5.3 Label in Name - the name shown (and the initials of the
|
||||
//- avatar) are part of the accessible name, followed by what the link opens.
|
||||
a.header-user-bar-name.js-open-header-member-menu(title="{{_ 'memberMenuPopup-title'}}" aria-label="{{userBarLabel}}")
|
||||
.header-user-bar-avatar
|
||||
+userAvatar(userId=currentUser._id)
|
||||
unless isMiniScreen
|
||||
|
||||
@@ -12,6 +12,19 @@ import { detectAvailableFonts } from '/client/lib/fontDetector';
|
||||
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue } from '/models/lib/uiFonts';
|
||||
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
|
||||
|
||||
// WCAG 2.5.3 Label in Name: the link shows the avatar's initials (when the avatar
|
||||
// is not a picture) and the name, so its accessible name starts with exactly
|
||||
// those, followed by what it opens.
|
||||
Template.headerUserBar.helpers({
|
||||
userBarLabel() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return TAPi18n.__('memberMenuPopup-title');
|
||||
const initials = Template.userAvatar.__helpers.get('initialsInName').call({ userId: user._id });
|
||||
const name = (user.profile && user.profile.fullname) || user.username || '';
|
||||
return `${initials}${name} ${TAPi18n.__('memberMenuPopup-title')}`.replace(/\s+/g, ' ').trim();
|
||||
},
|
||||
});
|
||||
|
||||
Template.headerUserBar.events({
|
||||
'click .js-open-header-member-menu': Popup.open('memberMenu'),
|
||||
'click .js-change-avatar': Popup.open('changeAvatar'),
|
||||
|
||||
@@ -419,7 +419,7 @@ export default Popup => {
|
||||
const fileFullPath = `${zipDirName}/webfonts/${filename}`;
|
||||
zip.file(fileFullPath, responseBody);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFontAwesomeFonts = async (zip, zipDirName) => {
|
||||
// List of Font Awesome webfont files to include
|
||||
@@ -476,7 +476,7 @@ export default Popup => {
|
||||
} catch (e) {
|
||||
console.log('Error in downloadFontAwesomeFonts:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCardCovers = async (elements, zip, boardSlug, clonedHtmlElement, zipDirName) => {
|
||||
await asyncForEach(elements, async elem => {
|
||||
|
||||
+2
-2
@@ -119,7 +119,7 @@ window.Popup = new (class {
|
||||
dataContext: openOptions.dataContext || (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
|
||||
});
|
||||
|
||||
const $contentWrapper = $('.content-wrapper')
|
||||
const $contentWrapper = $('.content-wrapper');
|
||||
if ($contentWrapper.length > 0) {
|
||||
const contentWrapper = $contentWrapper[0];
|
||||
self._getTopStack().scrollTop = contentWrapper.scrollTop;
|
||||
@@ -195,7 +195,7 @@ window.Popup = new (class {
|
||||
/// steps back is greater than the popup stack size, the popup will be closed.
|
||||
back(n = 1) {
|
||||
if (this._stack.length > n) {
|
||||
const $contentWrapper = $('.content-wrapper')
|
||||
const $contentWrapper = $('.content-wrapper');
|
||||
if ($contentWrapper.length > 0) {
|
||||
const contentWrapper = $contentWrapper[0];
|
||||
const stack = this._stack[this._stack.length - n];
|
||||
|
||||
@@ -111,7 +111,7 @@ from a probe that runs at startup and every five minutes:
|
||||
| `db.network-filesystem` | The data directory is on a CIFS/SMB, NFS or FUSE filesystem. Only when WeKan can see that directory itself (the snap, the bundle with its embedded database, or a shared volume). |
|
||||
| `db.slow-storage` | Reads averaged over 100 ms across the interval - storage latency, the "Slow query" symptom. |
|
||||
| `db.disk-space` | The filesystem under the data directory is below 5% or 512 MiB free - the real "no space left on device", before it happens. |
|
||||
| `db.collection-scan` | MongoDB's own log (`getLog`) has "Slow query" lines where a query read a whole collection of at least 10,000 documents (COLLSCAN): an index WeKan does not know about is missing. The row names the collection and the fields filtered on - never values - so they can be reported to WeKan. Needs the `getLog` privilege (`clusterMonitor`); without it, and on FerretDB, this check is skipped. |
|
||||
| `db.collection-scan` | MongoDB's own log (`getLog`) has "Slow query" lines where a query read a whole collection of at least 10,000 documents (COLLSCAN): an index WeKan does not know about is missing. The row names the collection, the fields filtered on, the command (`find`, `distinct`, ...; for a slow `getMore`, the query it continues), the operators used on each field (`archived:$exists`, `$nor[sort:$gte$lte]`) and the MongoDB `queryHash` - never values - so they can be reported to WeKan and found with `explain()`. Needs the `getLog` privilege (`clusterMonitor`); without it, and on FerretDB, this check is skipped. |
|
||||
| `db.scan-rate` | The database read more than 50,000 documents per second over the 5-minute interval (`serverStatus` `scannedObjects`): queries without a fitting index, or one query asked far too often. |
|
||||
| `db.index-created` | WeKan found a collection with data but without an index it queries by, and created it. Fixed automatically; nothing to do. |
|
||||
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
# Trello API Python CLI
|
||||
# License: MIT / WeKan Team
|
||||
|
||||
try:
|
||||
# python 3
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlretrieve
|
||||
except ImportError:
|
||||
# python 2
|
||||
from urllib import urlencode
|
||||
|
||||
import json
|
||||
import requests
|
||||
import sys
|
||||
|
||||
@@ -71,7 +62,7 @@ if arguments == 0:
|
||||
print(" python3 api.py reactions ACTIONID # Reactions of ACTIONID")
|
||||
print(" python3 api.py attachments CARDID # List attachments of CARDID")
|
||||
print(" python3 api.py download ATTACHMENTURL # Download file from attachment URL like https://.../image.png with streaming and minimal RAM usage")
|
||||
exit
|
||||
sys.exit()
|
||||
|
||||
if arguments == 2:
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# Accessibility audit: automated axe-core trial
|
||||
|
||||
This page records the result of a small automated accessibility trial run on
|
||||
2026-11-02. It is a **measurement, not a conformance claim**. WeKan aims for
|
||||
[WCAG 2.1 AA](https://www.w3.org/WAI/WCAG21/quickref/) (see
|
||||
[Accessibility.md](Accessibility.md)); this audit shows where an automated
|
||||
scan finds that goal not yet met on seven key pages.
|
||||
|
||||
An automated scan finds only part of the problems: many WCAG criteria cannot
|
||||
be decided by a program, and the rest needs people using a keyboard and a
|
||||
screen reader. A clean result here would not have meant "accessible", and a
|
||||
finding here is a starting point, not a verdict.
|
||||
|
||||
## Status: fixed, and checked again
|
||||
|
||||
The findings below were **fixed the same day** in the default theme and the scan
|
||||
was run again. The result: **axe-core finds no violation on any of 22 pages**,
|
||||
for the same rule sets (`wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`,
|
||||
`best-practice`). This is a floor, not a conformance claim; the limits at the end
|
||||
of this page still apply.
|
||||
|
||||
### Before and after, page by page
|
||||
|
||||
The first scan covered 7 pages. For the re-check the list grew to 22, so the
|
||||
"before" column for the 15 added pages is a scan made before any fix, with the
|
||||
same tool, browser and viewport.
|
||||
|
||||
| Page | Violating elements before | After |
|
||||
|---|---|---|
|
||||
| Sign in | 6 | 0 |
|
||||
| Sign up | 6 | 0 |
|
||||
| All Boards (home) | 14 | 0 |
|
||||
| Templates | 13 | 0 |
|
||||
| Remaining | 14 | 0 |
|
||||
| Archive | 8 | 0 |
|
||||
| Public | 7 | 0 |
|
||||
| Activity | 7 | 0 |
|
||||
| Support | 7 | 0 |
|
||||
| Shortcuts | 7 | 0 |
|
||||
| My cards | 7 | 0 |
|
||||
| Due cards | 7 | 0 |
|
||||
| My attachments | 7 | 0 |
|
||||
| Starred items | 7 | 0 |
|
||||
| Global search | 15 | 0 |
|
||||
| Bookmarks | 8 | 0 |
|
||||
| Import | 6 | 0 |
|
||||
| Admin Panel: Settings | 17 | 0 |
|
||||
| Admin Panel: Information | 17 | 0 |
|
||||
| Admin Panel: Translation | 19 | 0 |
|
||||
| Accessibility page | 7 | 0 |
|
||||
| Board (swimlanes view) | 16 | 0 |
|
||||
| Board with a card open | 45 | 0 |
|
||||
|
||||
### What was done
|
||||
|
||||
| Finding | What was done |
|
||||
|---|---|
|
||||
| 1. Colour contrast | The default theme's blue is `#2573a7` (white 5.14, `#f2f2f2` 4.59) instead of `#2980b9`, everywhere the stylesheets, the theme accent table and the colour picker name it. The label colours green, red, sky, magenta and gray, and the Global search list chips, have black text instead of white. The quiet text greys `#8c8c8c` and `#727479` are `#595959` and `#5a5c61`; the grey board tiles are `#767676`; the "no items" text is `#595959`. |
|
||||
| 2. Label in Name | The Starred menu's name starts with the star count; the user menu link's name is the avatar's initials, the user's name and "Member Settings"; an avatar drawn as initials has the initials in its name. axe reads the letters inside the avatar's SVG as visible text whatever `aria-hidden` says, so the name has to contain them. |
|
||||
| 3. Fields without a label | The Pomodoro minutes field and the comment, description and edit-comment editors have an `aria-label`. The editor takes the key of its name (`labelKey`) and translates it, like every included template. |
|
||||
| 4 and 5. Button holding controls, role not allowed | A section heading in the card details is a real `h3` holding a real `button` (the WAI disclosure pattern) with `aria-expanded="true"` or `"false"`; the section menu and the layout toggle are beside the button. Enter, Space and a click on the heading still fold the section; this was checked in the browser. |
|
||||
| 6. Sign in and register | The form is the page's `main` landmark; the empty logo `h1` is a spacer, not a heading; the form's title is the level-one heading (the package draws it as an `h3`, so it gets `aria-level="1"`, and again whenever the form is redrawn). |
|
||||
| 7. No level-one heading | The page title in the header bar is a level-one heading (`role="heading"`); Activity, the import start page and Bookmarks got a title (they had none, so their heading would have been empty); the "No Due Cards Found" heading is an `h2`. |
|
||||
|
||||
### Two more found while adding the scan to the test suite
|
||||
|
||||
The Playwright spec runs in a 1280 x 720 window, shorter than the 1280 x 900 of
|
||||
the audit, and with a fresh user. It found two faults the audit had not:
|
||||
|
||||
- **`/shortcuts`**: the list scrolls in a short window and had nothing
|
||||
focusable, so a keyboard user could not scroll it (`scrollable-region-focusable`,
|
||||
WCAG 2.1.1). The list now has `tabindex="0"`, a `region` role and a name.
|
||||
- **`/bookmarks`**: the page had no title in the header bar, so the new
|
||||
level-one heading was empty (`empty-heading`). It is named "Starred boards".
|
||||
|
||||
This is also why the viewport belongs in an audit: the result depends on it.
|
||||
|
||||
### Checks that keep it fixed
|
||||
|
||||
| Check | Result on 2026-11-02 |
|
||||
|---|---|
|
||||
| `tests/accessibilityAuditFixes.test.cjs`: each fault pinned by its shape; contrast ratios are computed from the stylesheets, for every label colour, the header blue and the greys | 7 tests pass |
|
||||
| `tests/playwright/specs/45-axe-accessibility.e2e.js`: axe-core on the pages, fails on any violation, and presses Enter and Space on a section heading | 4 tests pass (Chromium) |
|
||||
| Four existing Playwright specs that touch the card sections, the date format and subtasks, run against the changed UI | 43 tests pass (Chromium) |
|
||||
| All Node suites (`node tests/run-node-suites.cjs`) | 2118 suites, 0 failed |
|
||||
|
||||
Five existing unit tests pinned the old markup (the old `#2980b9` in many
|
||||
stylesheets, a `role="button"` on the heading, `h3` in the title, a `#999`
|
||||
tile colour, an editor harness) and were updated to the new markup; each keeps
|
||||
the assertion that made it worth having.
|
||||
|
||||
### Known side effects of the colour change
|
||||
|
||||
- `#2980b9` was the default blue in 21 files; `#2573a7` is slightly darker and
|
||||
was checked by eye on the card and the sign-in page only.
|
||||
- The quiet greys changed in 11 stylesheets for the light themes. The dark
|
||||
themes have their own rules for the same text, and were not scanned, so a dark
|
||||
theme could show these greys differently.
|
||||
- The PDF export's progress bar still draws `#2980b9`; it is not part of the
|
||||
web pages.
|
||||
|
||||
The rest of this page is the audit as it was found, kept as a record.
|
||||
|
||||
## How it was run
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| WeKan | v12.83.0, release bundle built from `main`, started on its own port |
|
||||
| Database | FerretDB v1.104.0 with the SQLite backend (a fresh, empty database) |
|
||||
| Scanner | [axe-core](https://github.com/dequelabs/axe-core) 4.14.0, injected into each page |
|
||||
| Rule sets | `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` and `best-practice` |
|
||||
| Browser | Chromium 156 (headless), driven by Playwright |
|
||||
| Viewport | 1280 x 900 (laptop), one pass, no mobile size |
|
||||
| Theme and language | The default theme (blue header, `#2980b9`), English, light mode |
|
||||
| User | A seeded site admin, signed in with a resume token |
|
||||
| Data | One private board with three lists and three cards |
|
||||
|
||||
The scan ran after each page had settled (about 2.5 seconds after load).
|
||||
Sign-in and sign-up were scanned signed out; the other pages signed in.
|
||||
|
||||
## Summary
|
||||
|
||||
| Page | Rules violated | Elements |
|
||||
|---|---|---|
|
||||
| Sign in (`/sign-in`) | 4 | 6 |
|
||||
| Sign up (`/sign-up`) | 4 | 6 |
|
||||
| All Boards (`/`) | 2 | 14 |
|
||||
| Board, swimlanes view | 2 | 16 |
|
||||
| Board with a card open | 5 | 45 |
|
||||
| Global search (`/globalsearch`) | 2 | 7 |
|
||||
| Accessibility page (`/accessibility`) | 3 | 7 |
|
||||
|
||||
Counted by severity, the rules that were violated are:
|
||||
|
||||
| Impact | Rules | Where |
|
||||
|---|---|---|
|
||||
| Critical | `label` | card details |
|
||||
| Serious | `color-contrast`, `label-content-name-mismatch`, `nested-interactive` | header bars on every signed-in page; card details |
|
||||
| Moderate | `heading-order`, `landmark-one-main`, `region`, `page-has-heading-one` | sign in and up; accessibility page |
|
||||
| Minor | `empty-heading`, `aria-allowed-role` | sign in and up; card details |
|
||||
|
||||
axe also reported `incomplete` results, checks it could not decide and that need
|
||||
a person's eyes: colour contrast on 2 to 4 elements per signed-in page (text
|
||||
over a gradient or an image), and one Label in Name check on All Boards.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Colour contrast of the header bars (WCAG 1.4.3, AA, serious)
|
||||
|
||||
On every signed-in page the header text is lighter than AA allows on the
|
||||
default blue:
|
||||
|
||||
| Text | Background | Ratio | Needed | Where |
|
||||
|---|---|---|---|---|
|
||||
| `#ffffff` | `#2980b9` | 4.30 | 4.5 | page title in the header (14 px) |
|
||||
| `#f2f2f2` | `#2980b9` | 3.84 | 4.5 | the labels of the header buttons ("Sort Boards", "Notifications", "Private", "Watch" and the rest) and the user name |
|
||||
| `#f6f6f6` | `#2980b9` | 3.97 | 4.5 | text in a viewer inside a blue area |
|
||||
| `#ffffff` | `#999999` | 2.84 | 4.5 | a card label chip (`.label`) |
|
||||
| `#8c8c8c` | `#dedede` | 2.49 | 4.5 | the page heading on Global search (20 px) and an inline form placeholder |
|
||||
|
||||
This is the largest group: 11 elements on All Boards, 13 on a board and 20 with
|
||||
a card open. Most share one cause, the near-white text on `#2980b9`, so a
|
||||
change to that colour or to the text colour of the header buttons would likely
|
||||
remove most of them in one place. The Apple glass
|
||||
pastel theme was designed and measured against WCAG AA separately
|
||||
(`tests/playwright/specs/theme-appleglasspastel-contrast.e2e.js`); this scan
|
||||
used the default theme, so the two results do not contradict each other.
|
||||
|
||||
Other board colours were not scanned. Each has its own header colour, so each
|
||||
needs the same check (the fix changed only the default theme, `belize`).
|
||||
|
||||
### 2. Label in Name (WCAG 2.5.3, A, serious)
|
||||
|
||||
Three header controls have an `aria-label` that does not contain the text a
|
||||
sighted user sees, so a voice-control user who says what they see cannot
|
||||
activate them:
|
||||
|
||||
- the **Starred** menu (`.js-open-starred-boards`): the label is "Starred",
|
||||
the visible text is the star count (`0`);
|
||||
- the **Member Settings** link (`.header-user-bar-name`): the label is
|
||||
"Member Settings", but the visible text is something else (the user's name
|
||||
or avatar);
|
||||
- every **member avatar** (`a.member.js-member`): the label is the full name
|
||||
and, for an admin, the role ("... Admin"); the visible text is different
|
||||
(the initials).
|
||||
|
||||
Fix: make the accessible name start with, or contain, the visible text
|
||||
(for example "0 Starred", or the user's name followed by "Member Settings").
|
||||
It affects the header on every signed-in page (3 elements) and the avatar in
|
||||
the card details (a 4th).
|
||||
|
||||
### 3. Form fields without a label (WCAG 4.1.2 and 1.3.1, A, critical)
|
||||
|
||||
On the card details, two controls have no label, no `aria-label` and no
|
||||
`title`:
|
||||
|
||||
- the Pomodoro **work minutes** number input (`.js-pomodoro-work-minutes`);
|
||||
- the new **comment** editor (`textarea.js-new-comment-input`).
|
||||
|
||||
A screen reader announces both as "edit text" with no purpose.
|
||||
|
||||
### 4. Interactive controls inside a button (WCAG 4.1.2, A, serious)
|
||||
|
||||
In the card details, the section headings `h3.js-toggle-card-section` for
|
||||
**Custom Fields**, **Vote**, **Poker** and **Subtasks** carry `role="button"`
|
||||
and also contain other focusable controls: the section menu or edit link
|
||||
(`a.card-details-section-menu`, titled "Custom Fields", "Edit voting",
|
||||
"Edit Planning Poker" and "Subtasks") and, for Custom Fields, the layout toggle
|
||||
button. A button must not contain focusable descendants: the inner control
|
||||
cannot be reached or announced reliably.
|
||||
|
||||
Fix: keep the toggle and the action links as siblings in a wrapper instead of
|
||||
nesting them.
|
||||
|
||||
### 5. ARIA role not allowed on the element (best practice, minor)
|
||||
|
||||
All 15 section headings (`h3` with `role="button"`; the four of finding 4 are
|
||||
among them) are flagged by `aria-allowed-role`: an `h3` should not take the
|
||||
`button` role, because it loses its heading meaning. A `<button>` inside the
|
||||
`<h3>`, as the WAI disclosure pattern does, keeps both the outline and the
|
||||
control. The same change as finding 4 fixes this one too.
|
||||
|
||||
### 6. Page structure on sign in and sign up (best practice)
|
||||
|
||||
Both signed-out pages report the same four items:
|
||||
|
||||
- **`landmark-one-main`**: there is no `main` landmark;
|
||||
- **`region`**: the title, the "Register" / "sign in" link and the language
|
||||
picker are outside any landmark (3 elements);
|
||||
- **`empty-heading`**: the logo is an empty `<h1 class="at-form-landing-logo">`,
|
||||
so the page has a first-level heading with no text;
|
||||
- **`heading-order`**: the visible title "Sign In" / "Create an Account" is an
|
||||
`<h3>` directly after that empty `<h1>`.
|
||||
|
||||
The signed-in layout has landmarks (see the WCAG 1.3.1 test in
|
||||
`tests/playwright/specs/19-accessibility.e2e.js`); the sign-in form does not.
|
||||
Fix: wrap the form in `<main>`, give the title the `<h1>` and make the logo
|
||||
an image with an `alt`, or hide it from assistive technology.
|
||||
|
||||
### 7. Accessibility page has no level-one heading (best practice)
|
||||
|
||||
`/accessibility` has no `<h1>` (`page-has-heading-one`). The page title is
|
||||
shown in the header bar as a `span`.
|
||||
|
||||
## What passed
|
||||
|
||||
axe ran 48 distinct rules that passed on at least one of the pages, among
|
||||
them: the page language (`html-has-lang`, `html-lang-valid`), the document
|
||||
title, the skip link (`bypass`, `skip-link`), image alt text, link names,
|
||||
button names, select names, `aria-*` attribute and role validity, required
|
||||
ARIA attributes, `aria-hidden` on focusable elements, valid `tabindex`,
|
||||
scrollable regions that are focusable, lists, and meta viewport scaling
|
||||
(`meta-viewport`). A rule that passes on one page can still fail on another.
|
||||
The existing end-to-end test
|
||||
`19-accessibility.e2e.js` also checks the language, the skip link, the
|
||||
landmarks, the visible focus and the dialog control names.
|
||||
|
||||
## What this audit does not cover
|
||||
|
||||
- Only Chromium, one viewport (1280 x 900), the default theme, light mode.
|
||||
Not covered: the phone and tablet layouts, dark mode, high contrast, the
|
||||
other board colours, the other themes, and right-to-left languages.
|
||||
- Seven pages in one state. Not scanned: popups and menus (member menu, board
|
||||
menu, card menu, filter, sort), the hamburger sidebars, the Admin Panel, the
|
||||
card edit forms, the Gantt, Calendar and Table views, dragging, and the
|
||||
import and export pages.
|
||||
- Rules that need a person: reading order, whether a label makes sense, focus
|
||||
order across a drag, error messages, time limits, motion, and the quality of
|
||||
text alternatives. These need keyboard-only and screen-reader testing (NVDA,
|
||||
JAWS, VoiceOver, TalkBack).
|
||||
- No automated check for "target size" (WCAG 2.5.8, 2.2) or the other
|
||||
WCAG 2.2 criteria; axe 4.14 was run against the 2.0 and 2.1 sets only.
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
Steps 1 to 4 are done (see Status above), and the axe scan is in the Playwright
|
||||
suite as `45-axe-accessibility.e2e.js`. What is left:
|
||||
|
||||
1. Repeat the scan for the states and pages this audit did not reach: popups
|
||||
and menus, the hamburger sidebars, the Calendar, Gantt and Table views, the
|
||||
card edit forms, and the rest of the Admin Panel.
|
||||
2. Repeat it for the other board colours and themes, at a phone width, in dark
|
||||
mode and with a right-to-left language. The header colour of every board
|
||||
colour needs the 4.5 check that the default theme now passes.
|
||||
3. Test with people: keyboard only, and a screen reader (NVDA, JAWS, VoiceOver,
|
||||
TalkBack).
|
||||
|
||||
## How to repeat it
|
||||
|
||||
Run a WeKan server and a database it can reach, seed a user and a board (the
|
||||
Playwright helpers in `tests/playwright/helpers/db.js` do this), then for each
|
||||
page inject `axe.min.js` and call:
|
||||
|
||||
```js
|
||||
await axe.run(document, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
|
||||
resultTypes: ['violations', 'incomplete'],
|
||||
});
|
||||
```
|
||||
|
||||
`tests/playwright/specs/45-axe-accessibility.e2e.js` does exactly this for the
|
||||
pages above, with the `axe-core` package of `tests/playwright`, and signs in
|
||||
with `loginWithToken` from `tests/playwright/helpers/auth.js`. Run it like the
|
||||
other Playwright specs. The original trial used `axe-core` 4.14.0 and the
|
||||
Chromium of the repository's Playwright cache (`.tools/ms-playwright`).
|
||||
@@ -10,8 +10,6 @@
|
||||
# - There is hardcoded value of avatar URL example.com .
|
||||
# Try to change it to use existing environment variables.
|
||||
|
||||
import os
|
||||
|
||||
import environs
|
||||
import ldap
|
||||
import hashlib
|
||||
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-12.78-win64.zip](https://github.com/wekan/wekan/releases/download/v12.78/wekan-12.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-12.85-win64.zip](https://github.com/wekan/wekan/releases/download/v12.85/wekan-12.85-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
|
||||
2. [node.exe](https://nodejs.org/dist/latest-v26.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-12.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-12.85-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
@@ -31,6 +31,32 @@ Install WeKan's tools into `.tools` once, from a normal terminal **outside**
|
||||
the sandbox (`./build.sh`, option 1). Inside the sandbox they are already on
|
||||
`PATH`.
|
||||
|
||||
### Claude Code in the sandbox
|
||||
|
||||
Claude Code keeps its login in the macOS Keychain, which the sandbox cannot
|
||||
reach, so signing in inside the sandbox does not stick: macOS asks for a
|
||||
keychain to store "user" in, and Claude Code shows its login screen again.
|
||||
Give it a long-lived token instead, once, from a normal terminal **outside**
|
||||
the sandbox:
|
||||
|
||||
```bash
|
||||
claude setup-token # sign in in the browser; prints a token valid for a year
|
||||
docs/Security/Sandboxes/macOS/sandbox.sh --claude-token # copy the token first; saved to .tools/secrets/claude-oauth-token
|
||||
```
|
||||
|
||||
The token is printed over several lines: copy all of them. `--claude-token`
|
||||
reads it from the clipboard, joins the lines, and refuses a token that is
|
||||
cut short, which Claude Code would answer with "401 Invalid bearer token".
|
||||
In zsh, paste the commands without the `#` comments.
|
||||
|
||||
Then restart the sandboxed VSCodium. `sandbox.sh` passes the token as
|
||||
`CLAUDE_CODE_OAUTH_TOKEN`, marks Claude Code's onboarding done in
|
||||
`.tools/home/.claude.json`, and sets `"claudeCode.disableLoginPrompt": true`
|
||||
in VSCodium's settings, so neither the extension nor the `claude` command in
|
||||
the terminal asks for a login. Everything in the sandbox can read the token,
|
||||
as it could read any login made there; revoke it at claude.ai under
|
||||
Settings if it leaks, and make a new one when it expires.
|
||||
|
||||
## What the sandbox allows and refuses
|
||||
|
||||
`sandbox.sh --check` runs these checks inside the sandbox and prints the
|
||||
@@ -73,6 +99,10 @@ with the checkout's real path:
|
||||
`.tools/vscodium/user-data` and `.tools/vscodium/extensions`, given with
|
||||
`--user-data-dir` and `--extensions-dir`. They are not in `~/Library` or
|
||||
`~/.vscode-oss`, which the sandbox cannot reach.
|
||||
- **Started in the checkout:** `sandbox.sh` changes to the checkout before it
|
||||
starts anything. Finder opens the launcher in the home directory, which the
|
||||
sandbox refuses, and VSCodium then stopped at once with "EPERM:
|
||||
process.cwd failed with error operation not permitted, uv_cwd".
|
||||
- **Temporary files:** `TMPDIR`, `TMP` and `TEMP` are `.tools/tmp`, as for every
|
||||
WeKan script (`releases/tools-env.sh`).
|
||||
- **A clean environment:** VSCodium starts with `env -i`. That drops
|
||||
@@ -96,6 +126,21 @@ with the checkout's real path:
|
||||
reach anything outside the checkout. That is the property this sandbox
|
||||
exists for. The Flatpak build of VSCodium makes the same trade with
|
||||
zypak.
|
||||
- **No Keychain: `--use-mock-keychain`.** The login keychain is in
|
||||
`~/Library/Keychains`, which the sandbox refuses. Without this switch
|
||||
VSCodium's secret storage (Electron `safeStorage`) asks for a keychain to
|
||||
keep "VSCodium Key" in, with the dialog "A keychain cannot be found to store
|
||||
'VSCodium Key'" (in Finnish "Avainnippua ei löydy"). Never press **Reset To
|
||||
Defaults** (*Palauta oletuksiin*) there: it would replace the login keychain
|
||||
with a new, empty one. Press **Cancel** (*Kumoa*) and start VSCodium again
|
||||
with the updated `sandbox.sh`. With the switch, Chromium uses a built-in
|
||||
mock keychain with a fixed, publicly known key and does not touch the real
|
||||
Keychain (Claude Code's own login also lives in the Keychain; see
|
||||
"Claude Code in the sandbox" above); it is the macOS equivalent of `--password-store=basic`, which
|
||||
works on Linux only. The price: what extensions store as secrets (sign-ins,
|
||||
tokens) in `.tools/vscodium/user-data` is only obfuscated, readable by
|
||||
anyone who can read the checkout. Keep long-lived credentials out of the
|
||||
sandbox. Opening `~/Library/Keychains` to the sandbox instead would be worse.
|
||||
- **`sandbox-exec` is marked deprecated** in its man page, but it has been
|
||||
present and working in every macOS release since 10.5, including macOS 27,
|
||||
where this was tested. Apple uses the same profiles for its own services.
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
# sandbox.sh - install VSCodium and start it in a macOS sandbox that can read
|
||||
# and write only this WeKan checkout (~/Documents/repos/wekan).
|
||||
#
|
||||
# Usage: docs/Security/Sandboxes/macOS/sandbox.sh [--setup-only] [--check]
|
||||
# Usage: docs/Security/Sandboxes/macOS/sandbox.sh [--setup-only|--check|--claude-token]
|
||||
#
|
||||
# (no option) set up what is missing, then start VSCodium in the sandbox
|
||||
# --setup-only set up, and write the Finder launcher, but do not start it
|
||||
# --check show what the sandbox allows and refuses, then exit
|
||||
# (no option) set up what is missing, then start VSCodium in the sandbox
|
||||
# --setup-only set up, and write the Finder launcher, but do not start it
|
||||
# --check show what the sandbox allows and refuses, then exit
|
||||
# --claude-token save a token from `claude setup-token` for Claude Code
|
||||
# in the sandbox, then set up (see 2b below)
|
||||
#
|
||||
# The macOS counterpart of the Flatpak sandbox on Linux
|
||||
# (docs/Security/Sandboxes/vscodium). The design, what is and is not
|
||||
@@ -28,7 +30,8 @@ case "${1:-}" in
|
||||
'') ;;
|
||||
--setup-only) MODE=setup ;;
|
||||
--check) MODE=check ;;
|
||||
*) echo "Usage: $0 [--setup-only] [--check]" >&2; exit 2 ;;
|
||||
--claude-token) MODE=claude-token ;;
|
||||
*) echo "Usage: $0 [--setup-only|--check|--claude-token]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
@@ -40,6 +43,11 @@ TOOLS="$REPO/.tools"
|
||||
APP="${VSCODIUM_APP:-/Applications/VSCodium.app}"
|
||||
PROFILE="$TOOLS/sandbox/vscodium-macos.sb"
|
||||
LAUNCHER="$TOOLS/sandbox/WeKan VSCodium.command"
|
||||
# Work from the checkout. Finder starts the launcher in the home directory,
|
||||
# which the sandbox refuses: VSCodium (Node.js) then fails at its first
|
||||
# process.cwd() with "EPERM: process.cwd failed with error operation not
|
||||
# permitted, uv_cwd", and so would any other program started in the sandbox.
|
||||
cd "$REPO"
|
||||
mkdir -p "$TOOLS/tmp" "$TOOLS/home" "$TOOLS/sandbox" \
|
||||
"$TOOLS/vscodium/user-data" "$TOOLS/vscodium/extensions"
|
||||
|
||||
@@ -68,6 +76,65 @@ for key in user.name user.email; do
|
||||
fi
|
||||
done
|
||||
|
||||
# 2b) Claude Code's sign-in. Claude Code keeps its login in the macOS Keychain,
|
||||
# which the sandbox cannot reach ("A keychain cannot be found to store
|
||||
# 'user'"), so a login made inside the sandbox is lost at once. Instead a
|
||||
# long-lived token, made with `claude setup-token` outside the sandbox, is
|
||||
# kept in .tools/secrets/claude-oauth-token and given to the sandbox as
|
||||
# CLAUDE_CODE_OAUTH_TOKEN. Save it with: sandbox.sh --claude-token
|
||||
CLAUDE_TOKEN_FILE="$TOOLS/secrets/claude-oauth-token"
|
||||
if [ "$MODE" = claude-token ]; then
|
||||
(umask 077; mkdir -p "$TOOLS/secrets")
|
||||
# `claude setup-token` prints the token wrapped over several lines, so
|
||||
# reading one pasted line would keep only the first part of it ("401
|
||||
# Invalid bearer token"). Take it from the clipboard, or read lines until
|
||||
# an empty one, and drop the line breaks.
|
||||
token="$(pbpaste 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
case "$token" in
|
||||
sk-ant-*) echo "Token taken from the clipboard." ;;
|
||||
*)
|
||||
echo 'Paste the token from `claude setup-token` (not shown), then press Return twice:'
|
||||
token=""
|
||||
while IFS= read -rs line && [ -n "$line" ]; do token="$token$line"; done
|
||||
token="$(printf '%s' "$token" | tr -d '[:space:]')"
|
||||
;;
|
||||
esac
|
||||
case "$token" in
|
||||
sk-ant-oat*) ;;
|
||||
*) echo "That is not a token from \`claude setup-token\` (it starts with sk-ant-oat); nothing saved." >&2; exit 1 ;;
|
||||
esac
|
||||
if [ "${#token}" -lt 90 ]; then
|
||||
echo "The token is only ${#token} characters, so part of it is missing; nothing saved." >&2
|
||||
echo "Copy all of it, every line, and run this again." >&2
|
||||
exit 1
|
||||
fi
|
||||
(umask 077; printf '%s\n' "$token" > "$CLAUDE_TOKEN_FILE")
|
||||
echo "Saved to $CLAUDE_TOKEN_FILE (readable only by you)."
|
||||
MODE=setup
|
||||
fi
|
||||
claude_env=()
|
||||
if [ -s "$CLAUDE_TOKEN_FILE" ]; then
|
||||
claude_env=(CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '[:space:]' < "$CLAUDE_TOKEN_FILE")")
|
||||
# With a token Claude Code would still show its login screen until its
|
||||
# onboarding is marked done, and the VS Code extension until its login
|
||||
# prompt is turned off.
|
||||
cj="$TOOLS/home/.claude.json"
|
||||
if [ ! -s "$cj" ]; then
|
||||
printf '{\n "hasCompletedOnboarding": true\n}\n' > "$cj"
|
||||
elif [ "$(plutil -extract hasCompletedOnboarding raw -o - "$cj" 2>/dev/null)" != true ]; then
|
||||
cp "$cj" "$cj.bak"
|
||||
plutil -replace hasCompletedOnboarding -bool YES "$cj" 2>/dev/null ||
|
||||
echo "Note: add \"hasCompletedOnboarding\": true to $cj by hand." >&2
|
||||
fi
|
||||
vs="$TOOLS/vscodium/user-data/User/settings.json"
|
||||
mkdir -p "$(dirname "$vs")"
|
||||
if [ ! -s "$vs" ]; then
|
||||
printf '{\n "claudeCode.disableLoginPrompt": true\n}\n' > "$vs"
|
||||
elif ! grep -q '"claudeCode.disableLoginPrompt"' "$vs"; then
|
||||
echo "Note: add \"claudeCode.disableLoginPrompt\": true to VSCodium's settings ($vs)." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3) The sandbox profile (Seatbelt, the language of sandbox-exec), written for
|
||||
# this checkout's own path. Everything is allowed except the file system
|
||||
# under /Users and /Volumes, of which only the checkout is opened again.
|
||||
@@ -113,6 +180,7 @@ sandboxed() {
|
||||
WEKAN_DIR="$REPO" WEKAN_TOOLS_DIR="$TOOLS" \
|
||||
npm_config_cache="$TOOLS/npm-cache" PLAYWRIGHT_BROWSERS_PATH="$TOOLS/ms-playwright" \
|
||||
PATH="$TOOLS/bin:$TOOLS/.meteor:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
${claude_env[@]+"${claude_env[@]}"} \
|
||||
"$@"
|
||||
}
|
||||
|
||||
@@ -141,8 +209,17 @@ echo "Sandbox ready. Finder launcher: $LAUNCHER"
|
||||
# initialization failed: Operation not permitted"), and every process
|
||||
# VSCodium starts is inside this sandbox instead. Its settings and
|
||||
# extensions are in .tools/vscodium, not in ~/Library and ~/.vscode-oss.
|
||||
sandboxed "$APP/Contents/MacOS/VSCodium" --no-sandbox \
|
||||
# --use-mock-keychain: the login keychain is in ~/Library/Keychains, which
|
||||
# the sandbox refuses, so Electron's safeStorage would ask for a keychain
|
||||
# to store "VSCodium Key" in ("A keychain cannot be found", with a "Reset
|
||||
# To Defaults" button that would replace the user's login keychain). With
|
||||
# it, Chromium's OSCrypt uses a mock keychain with a fixed key and never
|
||||
# touches the real one, so extension secrets in .tools/vscodium are only
|
||||
# obfuscated. (--password-store=basic does the same on Linux only.)
|
||||
# nohup and </dev/null: the launcher's Terminal window may close as soon
|
||||
# as this script exits, and VSCodium must not go with it.
|
||||
sandboxed /usr/bin/nohup "$APP/Contents/MacOS/VSCodium" --no-sandbox --use-mock-keychain \
|
||||
--user-data-dir "$TOOLS/vscodium/user-data" \
|
||||
--extensions-dir "$TOOLS/vscodium/extensions" \
|
||||
"$REPO" >"$TOOLS/sandbox/vscodium.log" 2>&1 &
|
||||
"$REPO" </dev/null >"$TOOLS/sandbox/vscodium.log" 2>&1 &
|
||||
echo "VSCodium started in the sandbox (log: $TOOLS/sandbox/vscodium.log)."
|
||||
@@ -50,26 +50,98 @@ function shiftCalendarMonth(date, direction, calendar) {
|
||||
return candidate < next.end ? candidate : shiftedDay(next.end, -1);
|
||||
}
|
||||
|
||||
function shiftCalendarYear(date, direction, calendar) {
|
||||
const yearKey = value => {
|
||||
const parts = nativeCalendarParts(value, calendar);
|
||||
return `${parts.era || ''}|${parts.year}`;
|
||||
};
|
||||
const range = anchor => {
|
||||
const key = yearKey(anchor);
|
||||
let start = shiftedDay(anchor, 0);
|
||||
let end = shiftedDay(anchor, 1);
|
||||
for (let count = 0; count < 400 && yearKey(shiftedDay(start, -1)) === key; count += 1) start = shiftedDay(start, -1);
|
||||
for (let count = 0; count < 400 && yearKey(end) === key; count += 1) end = shiftedDay(end, 1);
|
||||
return { start, end };
|
||||
};
|
||||
const current = range(date);
|
||||
const next = range(direction < 0 ? shiftedDay(current.start, -1) : current.end);
|
||||
let offset = 0;
|
||||
const anchor = shiftedDay(date, 0);
|
||||
for (let day = current.start; day < anchor && offset < 400; day = shiftedDay(day, 1)) offset += 1;
|
||||
function calendarYearKey(date, calendar) {
|
||||
const parts = nativeCalendarParts(date, calendar);
|
||||
return `${parts.era || ''}|${parts.year}`;
|
||||
}
|
||||
|
||||
// The first day (noon) and the day after the last of the calendar year that
|
||||
// holds `date`.
|
||||
function calendarYearRange(date, calendar) {
|
||||
const key = calendarYearKey(date, calendar);
|
||||
let start = shiftedDay(date, 0);
|
||||
let end = shiftedDay(date, 1);
|
||||
for (let count = 0; count < 400 && calendarYearKey(shiftedDay(start, -1), calendar) === key; count += 1) start = shiftedDay(start, -1);
|
||||
for (let count = 0; count < 400 && calendarYearKey(end, calendar) === key; count += 1) end = shiftedDay(end, 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// The same day of the year in the year that holds `target`, or that year's
|
||||
// last day when it is shorter.
|
||||
function sameDayInYear(date, target, calendar) {
|
||||
const current = calendarYearRange(date, calendar);
|
||||
const next = calendarYearRange(target, calendar);
|
||||
const offset = Math.round((shiftedDay(date, 0) - current.start) / 86400000);
|
||||
const candidate = shiftedDay(next.start, offset);
|
||||
return candidate < next.end ? candidate : shiftedDay(next.end, -1);
|
||||
}
|
||||
|
||||
module.exports = { calendarMonthRange, calendarMonthDays, shiftedDay, shiftCalendarMonth, shiftCalendarYear };
|
||||
function shiftCalendarYear(date, direction, calendar) {
|
||||
const current = calendarYearRange(date, calendar);
|
||||
const next = calendarYearRange(direction < 0 ? shiftedDay(current.start, -1) : current.end, calendar);
|
||||
return sameDayInYear(date, next.start, calendar);
|
||||
}
|
||||
|
||||
// #6853: the year as a person counts it in this calendar, as a whole number
|
||||
// that may be zero or negative: 2026, then 1 for the first year of the era,
|
||||
// then 0 for the year before it (1 BC), -1 for 2 BC - so that any year, ancient
|
||||
// or far ahead, is one number to type or to step to. null when the calendar or
|
||||
// the date is unsupported.
|
||||
const BEFORE_ERA = /^(BC|BCE|BH|ERA0|Before)/i;
|
||||
function calendarSignedYear(date, calendar) {
|
||||
const parts = nativeCalendarParts(date, calendar);
|
||||
const year = parts && Number(parts.year);
|
||||
if (!Number.isFinite(year)) return null;
|
||||
return BEFORE_ERA.test(parts.era || '') ? 1 - year : year;
|
||||
}
|
||||
|
||||
// A day in the year `target` (a signed year, see above) of the calendar, the
|
||||
// same day of the year as `date` has in its own year, or null when no such day
|
||||
// can be represented (a JavaScript date reaches about 275,000 years either
|
||||
// side of 1970). The year is not walked to: each step moves by the number of
|
||||
// years still missing times the mean year, which closes in within a handful of
|
||||
// steps even for a lunar calendar, whose year is 11 days shorter. That keeps a
|
||||
// jump of several thousand years as cheap as a jump of one.
|
||||
const MEAN_YEAR_DAYS = 365.2425;
|
||||
function dateInCalendarYear(date, target, calendar) {
|
||||
if (!Number.isInteger(target)) return null;
|
||||
try {
|
||||
let guess = shiftedDay(date, 0);
|
||||
for (let step = 0; step < 40; step += 1) {
|
||||
const year = calendarSignedYear(guess, calendar);
|
||||
if (year === null) return null;
|
||||
if (year === target) return sameDayInYear(date, guess, calendar);
|
||||
const next = shiftedDay(guess, Math.round((target - year) * MEAN_YEAR_DAYS) || (target > year ? 1 : -1));
|
||||
if (Number.isNaN(next.getTime())) return null;
|
||||
guess = next;
|
||||
}
|
||||
} catch (error) { /* out of the range a Date can hold */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// The months of the calendar year that holds `date`, each with its first day
|
||||
// (noon) - leap months and a short thirteenth month included.
|
||||
function calendarYearMonths(date, calendar) {
|
||||
const year = calendarYearRange(date, calendar);
|
||||
const current = calendarMonthRange(date, calendar).start.getTime();
|
||||
const months = [];
|
||||
for (let start = year.start; start < year.end && months.length < 20;) {
|
||||
const range = calendarMonthRange(start, calendar);
|
||||
months.push({ date: shiftedDay(range.start, 0), selected: range.start.getTime() === current });
|
||||
start = shiftedDay(range.end, 0);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
// The day of `date` in the month that starts at `monthStart`, or that month's
|
||||
// last day when it is shorter.
|
||||
function sameDayInMonth(date, monthStart, calendar) {
|
||||
const next = calendarMonthRange(monthStart, calendar);
|
||||
const candidate = shiftedDay(next.start, Number(nativeCalendarParts(date, calendar).day) - 1);
|
||||
return candidate < next.end ? candidate : shiftedDay(next.end, -1);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calendarMonthRange, calendarMonthDays, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
|
||||
calendarYearRange, calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInYear, sameDayInMonth,
|
||||
};
|
||||
@@ -85,4 +85,16 @@ export function shouldShowAnnouncement({ enabled, version, dismissedVersion }) {
|
||||
return dismissedVersion !== version;
|
||||
}
|
||||
|
||||
// #6854: who the site announcement is for. A signed-in user on a page with no
|
||||
// board of its own - Home and every All Boards list: Remaining, Starred,
|
||||
// Templates, Archive - and a member of the board that is open on a board page.
|
||||
// It used to ask for membership of the current board on every page, and there
|
||||
// is none on those, so it showed only on boards. Nobody signed out sees it
|
||||
// (#1281).
|
||||
export function showAnnouncementToUser({ userId, board }) {
|
||||
if (!userId) return false;
|
||||
if (!board) return true;
|
||||
return typeof board.hasMember === 'function' ? !!board.hasMember(userId) : false;
|
||||
}
|
||||
|
||||
export default Announcements;
|
||||
@@ -55,7 +55,7 @@ const Attachments = new FilesCollection({
|
||||
allowClientCode: true,
|
||||
storagePath: storagePath,
|
||||
namingFunction(opts) {
|
||||
let filenameWithoutExtension = ""
|
||||
let filenameWithoutExtension = "";
|
||||
let fileId = "";
|
||||
if (opts?.name) {
|
||||
// Client
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ const Avatars = new FilesCollection({
|
||||
allowClientCode: true,
|
||||
storagePath: storagePath,
|
||||
namingFunction(opts) {
|
||||
let filenameWithoutExtension = ""
|
||||
let filenameWithoutExtension = "";
|
||||
let fileId = "";
|
||||
if (opts?.name) {
|
||||
// Client
|
||||
|
||||
+10
-5
@@ -4155,10 +4155,15 @@ const addCronJob = debounce(
|
||||
}
|
||||
}
|
||||
Meteor.setTimeout(scheduler, hour);
|
||||
})(() => {
|
||||
findDueCards(notifydays).catch(error => {
|
||||
})(async () => {
|
||||
try {
|
||||
// Once per day for the whole installation, not once per server.
|
||||
const { claimDueScanDay } = require('/models/lib/dueScanClaim');
|
||||
if (!(await claimDueScanDay(Cards.rawDatabase()))) return;
|
||||
await findDueCards(notifydays);
|
||||
} catch (error) {
|
||||
console.error('Due card notification scan failed:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
scheduler();
|
||||
},
|
||||
@@ -4238,8 +4243,8 @@ Cards.helpers({
|
||||
/**
|
||||
* Get a description of the original position
|
||||
*/
|
||||
getOriginalPositionDescription() {
|
||||
const history = this.getOriginalPosition();
|
||||
async getOriginalPositionDescription() {
|
||||
const history = await this.getOriginalPosition();
|
||||
if (!history) return 'No original position data';
|
||||
|
||||
const swimlaneInfo = history.originalSwimlaneId ?
|
||||
|
||||
+1
-1
@@ -652,7 +652,7 @@ if (Meteor.isServer) {
|
||||
|
||||
let userLanguage = 'en';
|
||||
if (user && user.profile) {
|
||||
userLanguage = user.profile.language
|
||||
userLanguage = user.profile.language;
|
||||
}
|
||||
|
||||
if( req.query.delimiter == "\t" ) {
|
||||
|
||||
+1
-20
@@ -15,26 +15,7 @@ const {
|
||||
} = require('./lib/importExportSecurity');
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
formatTime,
|
||||
getISOWeek,
|
||||
isValidDate,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isSame,
|
||||
add,
|
||||
subtract,
|
||||
startOf,
|
||||
endOf,
|
||||
format,
|
||||
parseDate,
|
||||
now,
|
||||
createDate,
|
||||
fromNow,
|
||||
calendar
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { add, format, now } from '/imports/lib/dateUtils';
|
||||
|
||||
//const stringify = require('csv-stringify');
|
||||
|
||||
|
||||
@@ -32,4 +32,24 @@ function counterFloor(cardNumber, boardTop) {
|
||||
return isCardNumber(boardTop) ? Math.max(cardNumber, boardTop) : cardNumber;
|
||||
}
|
||||
|
||||
module.exports = { counterName, isCardNumber, needsNewCardNumber, counterFloor };
|
||||
// #6815 follow-up: an UPDATE can bring a card into a board's numbering too.
|
||||
// - A move to another board: the browser computes the destination number
|
||||
// itself (Boards.getNextCardNumber on the client is the highest number among
|
||||
// the destination cards it has loaded, plus one - often none of them).
|
||||
// - A restore from the archive: an archived card may share its number with an
|
||||
// open card from before the insert check existed.
|
||||
// - A $set of cardNumber: from the REST API or an import.
|
||||
// Returns { boardId, cardNumber } to check, or null when the update leaves the
|
||||
// card's place in the numbering alone.
|
||||
function numberToCheckOnUpdate(doc, set) {
|
||||
if (!doc || !set || typeof set !== 'object') return null;
|
||||
const movesBoard = set.boardId !== undefined && String(set.boardId) !== String(doc.boardId);
|
||||
const restores = set.archived === false && doc.archived === true;
|
||||
const setsNumber = set.cardNumber !== undefined;
|
||||
if (!movesBoard && !restores && !setsNumber) return null;
|
||||
const cardNumber = setsNumber ? set.cardNumber : doc.cardNumber;
|
||||
if (!isCardNumber(cardNumber)) return null;
|
||||
return { boardId: movesBoard ? set.boardId : doc.boardId, cardNumber };
|
||||
}
|
||||
|
||||
module.exports = { counterName, isCardNumber, needsNewCardNumber, counterFloor, numberToCheckOnUpdate };
|
||||
@@ -19,7 +19,14 @@ const DATABASES = ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'
|
||||
// `act` is the thing WeKan can do BY ITSELF when it sees this - and only where
|
||||
// doing it is obviously right. Everything else is advice, because a database
|
||||
// that is out of disk is not something an application should "handle".
|
||||
const CONNECTION_LOST = /connection refused|ECONNREFUSED|server closed the connection|connection reset by peer|Topology is closed|no reachable servers|Socket '\w+' timed out|MongoNetworkTimeoutError|MongoNetworkError|Server selection timed out|MongoServerSelectionError|PoolClearedError|NotWritablePrimary|NotPrimaryNoSecondaryOk|NotPrimaryOrSecondary|not primary(?! key)/i;
|
||||
// "Connection pool for <host> was cleared" is the PoolClearedError's own text. A
|
||||
// wrapper (collection2's "ValidationError: Failed validation ...") carries the
|
||||
// text without the class name, and <host> is a replica set member's name from
|
||||
// the set's own configuration (wekan-mongodb-0.wekan-mongodb-internal in a
|
||||
// Kubernetes StatefulSet), which MONGO_URL does not list - so the
|
||||
// "getaddrinfo ENOTFOUND" behind it was not the database's either, and the
|
||||
// schema rule matched (#6860).
|
||||
const CONNECTION_LOST = /connection refused|ECONNREFUSED|server closed the connection|connection reset by peer|Topology is closed|no reachable servers|Socket '\w+' timed out|MongoNetworkTimeoutError|MongoNetworkError|Server selection timed out|MongoServerSelectionError|PoolClearedError|Connection pool for \S+ was cleared|NotWritablePrimary|NotPrimaryNoSecondaryOk|NotPrimaryOrSecondary|not primary(?! key)/i;
|
||||
|
||||
// The hosts of a MongoDB connection string - mongodb://user:pw@a:27017,b/db or
|
||||
// mongodb+srv://host/db - lower case, without ports.
|
||||
|
||||
@@ -211,6 +211,31 @@ const SCAN_RATE_PER_SEC = 50000;
|
||||
const fieldShape = filter => (filter && typeof filter === 'object' && !Array.isArray(filter)
|
||||
? Object.keys(filter).sort().join(',') : '');
|
||||
|
||||
// The query's shape: each field with the operators used on it, never a value
|
||||
// ({archived: {$exists: false}} -> "archived:$exists", {a: 'x'} -> "a:=").
|
||||
// $or / $nor / $and list their branches, so two queries on the same fields
|
||||
// that need different indexes can be told apart.
|
||||
function queryShape(filter, depth = 0) {
|
||||
if (!filter || typeof filter !== 'object' || Array.isArray(filter) || depth > 3) return '';
|
||||
return Object.keys(filter).sort().map(key => {
|
||||
const value = filter[key];
|
||||
if (/^\$(or|nor|and)$/.test(key) && Array.isArray(value)) {
|
||||
return `${key}[${value.map(branch => queryShape(branch, depth + 1)).join('|')}]`;
|
||||
}
|
||||
const ops = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? Object.keys(value).filter(k => k.startsWith('$')).sort() : [];
|
||||
return `${key}:${ops.length ? ops.join('') : '='}`;
|
||||
}).join(',');
|
||||
}
|
||||
|
||||
// The command a slow getMore continues is in originatingCommand: without it a
|
||||
// cursor that scans in batches showed as "filter on nothing".
|
||||
const originOf = attr => {
|
||||
const cmd = (attr && attr.command) || {};
|
||||
return cmd.getMore !== undefined && attr.originatingCommand ? attr.originatingCommand : cmd;
|
||||
};
|
||||
const commandName = cmd => Object.keys(cmd || {}).find(k => !k.startsWith('$') && k !== 'lsid') || '';
|
||||
|
||||
function slowQueryFilter(command) {
|
||||
const cmd = command || {};
|
||||
const first = list => (Array.isArray(list) && list[0]) || null;
|
||||
@@ -236,9 +261,12 @@ function collectionScans(logLines, sinceMs = 0) {
|
||||
const a = entry.msg === 'Slow query' && entry.attr;
|
||||
if (!a || typeof a.ns !== 'string' || /^(local|config|admin)\./.test(a.ns)) continue;
|
||||
if (!/COLLSCAN/.test(String(a.planSummary || '')) || !(Number(a.docsExamined) >= SCAN_MIN_DOCS)) continue;
|
||||
const fields = fieldShape(slowQueryFilter(a.command));
|
||||
const command = originOf(a);
|
||||
const filter = slowQueryFilter(command);
|
||||
const fields = fieldShape(filter);
|
||||
const key = `${a.ns}|${fields}`;
|
||||
const g = groups.get(key) || { ns: a.ns, fields, count: 0, maxDocs: 0, maxMs: 0 };
|
||||
const g = groups.get(key) || { ns: a.ns, fields, count: 0, maxDocs: 0, maxMs: 0,
|
||||
op: commandName(command), shape: queryShape(filter), queryHash: String(a.queryHash || a.planCacheShapeHash || '') };
|
||||
g.count += 1;
|
||||
g.maxDocs = Math.max(g.maxDocs, Number(a.docsExamined) || 0);
|
||||
g.maxMs = Math.max(g.maxMs, Number(a.durationMillis) || 0);
|
||||
@@ -249,7 +277,8 @@ function collectionScans(logLines, sinceMs = 0) {
|
||||
|
||||
function collectionScanProblem(groups) {
|
||||
const list = (groups || []).slice(0, 3).map(g => `${g.ns} (filter on ${g.fields || 'nothing'}: `
|
||||
+ `${g.count}x, up to ${g.maxDocs} documents, ${g.maxMs} ms)`).join('; ');
|
||||
+ `${g.count}x, up to ${g.maxDocs} documents, ${g.maxMs} ms`
|
||||
+ `${g.op ? `; ${g.op}` : ''}${g.shape ? ` ${g.shape}` : ''}${g.queryHash ? `, queryHash ${g.queryHash}` : ''})`).join('; ');
|
||||
return {
|
||||
type: 'db.collection-scan',
|
||||
kind: 'index',
|
||||
@@ -281,6 +310,43 @@ function scanRateProblem(perSecond) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- one probe for the whole installation ----------------------------------
|
||||
|
||||
// Every WeKan server runs the probe, but they all look at the same database:
|
||||
// the same "Slow query" lines, the same counters. Each server reported each
|
||||
// finding itself, so 24 replicas wrote the same collection scan 24 times.
|
||||
// A probe first claims the period in one shared document; only the server
|
||||
// that gets it probes the database, and the state that compares one probe
|
||||
// with the next (log lines seen, counters, uptime) lives in that document, so
|
||||
// the next probe - on whichever server - continues where this one stopped.
|
||||
const PROBE_CLAIM_ID = 'database-health-probe';
|
||||
const SHARED_STATE = ['logSeenMs', 'scanned', 'uptime', 'restarts', 'opLatencies'];
|
||||
|
||||
// coll: a mongodb driver Collection. Resolves { claimed, shared }: `shared`
|
||||
// is the state the previous probe left ({} the very first time).
|
||||
async function claimProbe(coll, now, periodMs) {
|
||||
const due = now - Math.floor(periodMs * 0.8); // timers on the servers drift
|
||||
try {
|
||||
const r = await coll.findOneAndUpdate(
|
||||
{ _id: PROBE_CLAIM_ID, $or: [{ claimedMs: { $lt: due } }, { claimedMs: { $exists: false } }] },
|
||||
{ $set: { claimedMs: now } },
|
||||
{ upsert: true, returnDocument: 'before' },
|
||||
);
|
||||
// driver 6 returns the document, older drivers { value }
|
||||
const before = r && typeof r === 'object' && 'ok' in r && 'value' in r ? r.value : r;
|
||||
return { claimed: true, shared: before || {} };
|
||||
} catch (e) {
|
||||
if (e && e.code === 11000) return { claimed: false, shared: null }; // claimed elsewhere this period
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function sharedState(state) {
|
||||
const out = {};
|
||||
for (const k of SHARED_STATE) if (state[k] !== undefined) out[k] = state[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DOCS,
|
||||
NETWORK_FS_TYPES,
|
||||
@@ -306,6 +372,11 @@ module.exports = {
|
||||
SCAN_RATE_PER_SEC,
|
||||
collectionScans,
|
||||
collectionScanProblem,
|
||||
queryShape,
|
||||
claimProbe,
|
||||
sharedState,
|
||||
PROBE_CLAIM_ID,
|
||||
SHARED_STATE,
|
||||
scanRate,
|
||||
scanRateProblem,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
// The due-date reminder scan (models/cards.js findDueCards) runs once a day,
|
||||
// at NOTIFY_DUE_AT_HOUR_OF_DAY, on a timer in every WeKan server process. With
|
||||
// several servers on one database (a Kubernetes Deployment of 24 replicas)
|
||||
// every one of them scanned the due cards and inserted its own duenow /
|
||||
// almostdue / pastdue activity for each card - one reminder per server.
|
||||
// The first server to claim the day does the scan; the others skip.
|
||||
//
|
||||
// The claim is a document whose _id is the local date, inserted into
|
||||
// COLLECTION. The unique _id makes the insert succeed for exactly one server;
|
||||
// the others get a duplicate key error (11000). One small document per day.
|
||||
|
||||
const COLLECTION = 'dueReminderRuns';
|
||||
|
||||
function dayKey(date) {
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
// db: a mongodb driver Db. Resolves true when this server should scan today.
|
||||
async function claimDueScanDay(db, now = new Date()) {
|
||||
try {
|
||||
await db.collection(COLLECTION).insertOne({ _id: dayKey(now), at: now });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 11000) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { claimDueScanDay, dayKey, COLLECTION };
|
||||
@@ -833,13 +833,15 @@ export class FileStoreStrategyCloud extends FileStoreStrategy {
|
||||
this._uploadError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
try { spool.destroy(); } catch (e) { /* already closed */ }
|
||||
removeSpool().then(resolve);
|
||||
// removeSpool() catches its own error, so it never rejects.
|
||||
void removeSpool().then(resolve);
|
||||
};
|
||||
pass.on('error', fail);
|
||||
spool.on('error', fail);
|
||||
spool.on('finish', () => {
|
||||
if (this._uploadError) return;
|
||||
fs.promises.stat(spoolPath)
|
||||
// Every failure is caught below and the tail never rejects.
|
||||
void fs.promises.stat(spoolPath)
|
||||
.then(stat => adapter.storage.addFileFromPath({
|
||||
origPath: spoolPath,
|
||||
bucketName: adapter.bucketName,
|
||||
|
||||
@@ -12,7 +12,13 @@ export const createOnAfterUpload = bucket =>
|
||||
// then we read all versions we have got so far
|
||||
Object.keys(file.versions).forEach(versionName => {
|
||||
const metadata = { ...file.meta, versionName, fileId: file._id };
|
||||
fs.createReadStream(file.versions[versionName].path)
|
||||
const source = fs.createReadStream(file.versions[versionName].path);
|
||||
// pipe() does not pass a read error on to the upload stream: without
|
||||
// this a file that cannot be read left the upload open and the file in place.
|
||||
source.on('error', () => {
|
||||
self.unlink(file, versionName);
|
||||
});
|
||||
source
|
||||
|
||||
// this is where we upload the binary to the bucket using bucket.openUploadStream
|
||||
// see http://mongodb.github.io/node-mongodb-native/3.2/api/GridFSBucket.html#openUploadStream
|
||||
|
||||
@@ -39,6 +39,11 @@ const PAGE_TITLE_KEYS = {
|
||||
accessibility: 'accessibility',
|
||||
// Import names its source, so this key is only the fallback.
|
||||
import: 'import',
|
||||
// /import before a source is chosen, and the activity feed: pages with no name in
|
||||
// the bar had no level-one heading either (WCAG 1.3.1, 2.4.6).
|
||||
'import-start': 'import',
|
||||
'activity-feed': 'activity-feed-title',
|
||||
bookmarks: 'bookmarksPopup-title',
|
||||
};
|
||||
|
||||
// A second segment, after the page's own name: "Admin Panel / Settings".
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// parses that stylesheet and fails if the two ever disagree, so a new theme cannot
|
||||
// be added in CSS alone and quietly fall back to blue.
|
||||
const THEME_ACCENTS = {
|
||||
belize: '#2980b9',
|
||||
belize: '#2573a7',
|
||||
nephritis: '#27ae60',
|
||||
pomegranate: '#c0392b',
|
||||
pumpkin: '#e67e22',
|
||||
|
||||
@@ -4,26 +4,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { createWorkbook, createWorkbookWriter } from './createWorkbook';
|
||||
import { attachmentDisposition, exportFilename } from '/models/lib/exportFilename';
|
||||
import { anonymizeExportIdentities, anonymizeBoardTextInPlace, rewriteMentionsInText } from '/models/lib/importExportSecurity';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
formatTime,
|
||||
getISOWeek,
|
||||
isValidDate,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isSame,
|
||||
add,
|
||||
subtract,
|
||||
startOf,
|
||||
endOf,
|
||||
format,
|
||||
parseDate,
|
||||
now,
|
||||
createDate,
|
||||
fromNow,
|
||||
calendar
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { isValidDate, add } from '/imports/lib/dateUtils';
|
||||
|
||||
// exporter maybe is broken since Gridfs introduced, add fs and path
|
||||
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
mousedown,
|
||||
cont.md = function(e) {
|
||||
|
||||
obj = e.target
|
||||
noteEditableChild = false
|
||||
let obj = e.target;
|
||||
let noteEditableChild = false;
|
||||
while (obj != undefined && obj != null && obj.tagName.toUpperCase() != 'BODY'){
|
||||
if (obj.classList.contains("note-editable")){
|
||||
noteEditableChild = true
|
||||
noteEditableChild = true;
|
||||
}
|
||||
obj = obj.parentNode;
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
|| e.target.tagName === "TEXTAREA"
|
||||
|| noteEditableChild
|
||||
) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
const elementsOfClick = _document.elementsFromPoint(e.clientX, e.clientY);
|
||||
const hasNoDragScroll = elementsOfClick.every(elem => !elem.classList.contains('nodragscroll'));
|
||||
@@ -102,7 +102,7 @@
|
||||
);
|
||||
})(dragged[i++]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (_document.readyState == 'complete') {
|
||||
|
||||
@@ -383,7 +383,7 @@ class EntryPoint(object):
|
||||
try:
|
||||
data = load_return_type_jsdoc_json(data)
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
pass # not JSON: keep the text as it is
|
||||
|
||||
# we should not have 2 identical tags but @param or @tag
|
||||
if tag in self._doc:
|
||||
|
||||
Generated
+9
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.78.0",
|
||||
"version": "v12.85.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v12.78.0",
|
||||
"version": "v12.85.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -92,6 +92,7 @@
|
||||
"chai-as-promised": "^8.0.2",
|
||||
"css-loader": "^7.1.4",
|
||||
"enhanced-resolve": "5.26.0",
|
||||
"minimatch": "^11.0.1",
|
||||
"mongodb": "^7.7.0",
|
||||
"puppeteer": "^25.15.0",
|
||||
"sinon": "^22.1.0",
|
||||
@@ -12728,15 +12729,16 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
|
||||
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
"@isaacs/nonrecursive-glob-match": "^1.0.3",
|
||||
"brace-expansion": "^5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.78.0",
|
||||
"version": "v12.85.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"repository": {
|
||||
@@ -26,6 +26,7 @@
|
||||
"scripts": {
|
||||
"test": "EMAIL_RECEIPT_SWEEP_INTERVAL_MS=1000 WRITABLE_PATH=${WRITABLE_PATH:-..} meteor test --full-app --once --driver-package meteortesting:mocha",
|
||||
"test:unit:node": "node tests/run-node-suites.cjs",
|
||||
"lint:promises": "eslint --no-eslintrc -c .eslintrc.promises.json --no-ignore --ext .js,.cjs --max-warnings 0 models/lib server/lib imports/lib",
|
||||
"test:unit:all": "npm run test:unit:node",
|
||||
"test:watch": "WRITABLE_PATH=${WRITABLE_PATH:-..} meteor test --full-app --driver-package meteortesting:mocha",
|
||||
"test:e2e": "node tests/e2e/list-regressions.js",
|
||||
@@ -49,6 +50,7 @@
|
||||
"chai-as-promised": "^8.0.2",
|
||||
"css-loader": "^7.1.4",
|
||||
"enhanced-resolve": "5.26.0",
|
||||
"minimatch": "^11.0.1",
|
||||
"mongodb": "^7.7.0",
|
||||
"puppeteer": "^25.15.0",
|
||||
"sinon": "^22.1.0",
|
||||
@@ -131,6 +133,15 @@
|
||||
"archiver-utils": {
|
||||
"glob": "$glob"
|
||||
},
|
||||
"readdir-glob": {
|
||||
"minimatch": "^11.0.1"
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"minimatch": "^11.0.1"
|
||||
},
|
||||
"@babel/cli": {
|
||||
"minimatch": "^11.0.1"
|
||||
},
|
||||
"uuid": "^14.0.0",
|
||||
"webpack-dev-server": "^5.2.6",
|
||||
"http-proxy-middleware": "^2.0.10",
|
||||
|
||||
@@ -916,7 +916,7 @@ Object.assign(Session.prototype, {
|
||||
var forwardedFor = self.socket.headers["x-forwarded-for"];
|
||||
if (!isString(forwardedFor))
|
||||
return null;
|
||||
forwardedFor = forwardedFor.split(',')
|
||||
forwardedFor = forwardedFor.split(',');
|
||||
|
||||
// Typically the first value in the `x-forwarded-for` header is
|
||||
// the original IP address of the client connecting to the first
|
||||
@@ -1076,7 +1076,7 @@ Object.assign(Subscription.prototype, {
|
||||
try {
|
||||
await self._publishHandlerResult(await resultOrThenable);
|
||||
} catch(e) {
|
||||
self.error(e)
|
||||
self.error(e);
|
||||
}
|
||||
} else {
|
||||
await self._publishHandlerResult(resultOrThenable);
|
||||
@@ -1775,7 +1775,7 @@ Object.assign(Server.prototype, {
|
||||
* @returns boolean
|
||||
*/
|
||||
isAsyncCall: function(){
|
||||
return DDP._CurrentMethodInvocation._isCallAsyncMethodRunning()
|
||||
return DDP._CurrentMethodInvocation._isCallAsyncMethodRunning();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -618,11 +618,11 @@ Tinytest.addAsync('livedata server - publish cursor is properly awaited', async
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await coll.removeAsync({ _id: `item_${i}` })
|
||||
await coll.removeAsync({ _id: `item_${i}` });
|
||||
await coll.insertAsync({ _id: `item_${i}`, title: `Item #${i}` });
|
||||
}
|
||||
|
||||
const publicationName = `publication_${Random.id()}`
|
||||
const publicationName = `publication_${Random.id()}`;
|
||||
|
||||
delete Meteor.server.publish_handlers[publicationName];
|
||||
|
||||
@@ -642,18 +642,18 @@ Tinytest.addAsync('livedata server - publish cursor is properly awaited', async
|
||||
|
||||
await Meteor._sleepForMs(100);
|
||||
|
||||
const expectedMessages = ['sub', 'added', 'ready', 'sub', 'unsub', 'added', 'ready', 'nosub']
|
||||
const expectedMessages = ['sub', 'added', 'ready', 'sub', 'unsub', 'added', 'ready', 'nosub'];
|
||||
|
||||
/**
|
||||
* There shouldn't ever be `removed` messages here, otherwise the UI will glitch
|
||||
*/
|
||||
const parsedMessages = messages.map(m => m.msg)
|
||||
const parsedMessages = messages.map(m => m.msg);
|
||||
|
||||
test.equal(parsedMessages, expectedMessages)
|
||||
test.equal(parsedMessages, expectedMessages);
|
||||
|
||||
computation.stop();
|
||||
|
||||
cleanup()
|
||||
cleanup();
|
||||
});
|
||||
|
||||
Tinytest.addAsync('livedata server - stopping a handle should preserve its context on callbacks', async function (test) {
|
||||
@@ -664,11 +664,11 @@ Tinytest.addAsync('livedata server - stopping a handle should preserve its conte
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await coll.removeAsync({ _id: `item_${i}` })
|
||||
await coll.removeAsync({ _id: `item_${i}` });
|
||||
await coll.insertAsync({ _id: `item_${i}`, title: `Item #${i}` });
|
||||
}
|
||||
|
||||
const publicationName = `publication_${Random.id()}`
|
||||
const publicationName = `publication_${Random.id()}`;
|
||||
|
||||
delete Meteor.server.publish_handlers[publicationName];
|
||||
|
||||
@@ -676,7 +676,7 @@ Tinytest.addAsync('livedata server - stopping a handle should preserve its conte
|
||||
const user = {
|
||||
_id: 'user_id',
|
||||
customer: 'customer_id',
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
let count = 0;
|
||||
@@ -702,8 +702,8 @@ Tinytest.addAsync('livedata server - stopping a handle should preserve its conte
|
||||
|
||||
this.onStop(() => {
|
||||
// If stop is called and breaks for some reason, this will be false
|
||||
test.isTrue(handle._stopped)
|
||||
})
|
||||
test.isTrue(handle._stopped);
|
||||
});
|
||||
|
||||
this.ready();
|
||||
}
|
||||
@@ -751,7 +751,7 @@ function getTestConnections(test) {
|
||||
makeTestConnection(test, (clientConn, serverConn) => {
|
||||
resolve({ clientConn, serverConn });
|
||||
}, reject);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
|
||||
@@ -56,12 +56,12 @@ Google.requestCredential = (options, credentialRequestCompleteCallback) => {
|
||||
|
||||
// backwards compatible options
|
||||
if (options.requestOfflineToken != null){
|
||||
loginUrlParameters.access_type = options.requestOfflineToken ? 'offline' : 'online'
|
||||
loginUrlParameters.access_type = options.requestOfflineToken ? 'offline' : 'online';
|
||||
}
|
||||
if (options.prompt != null) {
|
||||
loginUrlParameters.prompt = options.prompt;
|
||||
} else if (options.forceApprovalPrompt) {
|
||||
loginUrlParameters.prompt = 'consent'
|
||||
loginUrlParameters.prompt = 'consent';
|
||||
}
|
||||
|
||||
if (options.loginHint) {
|
||||
|
||||
@@ -41,7 +41,7 @@ Meteor.initCas = function(callback) {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Meteor.loginWithCas = function(options, callback) {
|
||||
|
||||
@@ -69,7 +69,7 @@ Meteor.loginWithCas = function(options, callback) {
|
||||
|
||||
var loginUrl = settings.loginUrl +
|
||||
"?" + (settings.serviceParam || "service") + "=" +
|
||||
encodeURIComponent(serviceURL)
|
||||
encodeURIComponent(serviceURL);
|
||||
|
||||
if (!casUsesPopup(settings)) {
|
||||
window.location = loginUrl;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
// A WeKan account found by USERNAME or E-MAIL at an LDAP login (or merged by
|
||||
// the import) may already belong to another directory entry: its
|
||||
// services.ldap.id is that entry's unique identifier (objectGUID, entryUUID,
|
||||
// ...). That happens when a directory reuses a login name - someone leaves,
|
||||
// their account is deleted, and a new colleague later gets the same
|
||||
// sAMAccountName - or when two entries share an e-mail address. The login
|
||||
// matched the account by name and logged the NEW person into the OLD person's
|
||||
// account, with all of their boards.
|
||||
//
|
||||
// The account belongs to a different person when it records an identifier of
|
||||
// the SAME attribute with a DIFFERENT value. These cannot be told apart this
|
||||
// way, so they keep working as before:
|
||||
// - an account without an identifier (made before LDAP, or a local account
|
||||
// being merged);
|
||||
// - an identifier of another attribute: LDAP_UNIQUE_IDENTIFIER_FIELD was
|
||||
// changed, for example from the login name to objectGUID. The login then
|
||||
// records the new identifier, and the account is protected from then on;
|
||||
// - an identifier WITHOUT its attribute (accounts from before #4654 stored only
|
||||
// the value). It may be the login name's value while the login now offers an
|
||||
// objectGUID; refusing it would lock every such user out after that change.
|
||||
// The login records the attribute, so the next login is compared.
|
||||
//
|
||||
// CommonJS so the plain-Node test can require it.
|
||||
function boundToAnotherEntry(user, uniqueId) {
|
||||
const bound = user && user.services && user.services.ldap;
|
||||
if (!bound || !bound.id || !bound.idAttribute || !uniqueId || !uniqueId.value || !uniqueId.attribute) return false;
|
||||
if (String(bound.idAttribute).toLowerCase() !== String(uniqueId.attribute).toLowerCase()) return false;
|
||||
return String(bound.id) !== String(uniqueId.value);
|
||||
}
|
||||
|
||||
module.exports = { boundToAnotherEntry };
|
||||
@@ -544,7 +544,7 @@ export default class LDAP {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async getUserByUsername(username) {
|
||||
async getUserByUsername(username, { unique = false } = {}) {
|
||||
await this.bindIfNecessary();
|
||||
|
||||
const searchOptions = {
|
||||
@@ -563,6 +563,9 @@ export default class LDAP {
|
||||
}
|
||||
|
||||
if (result.length > 1) {
|
||||
if (unique) {
|
||||
throw new Error(`Search by username ${username} returned ${result.length} records; refusing an ambiguous LDAP background-sync update`);
|
||||
}
|
||||
Log.error(`Search by username ${username} returned ${result.length} records`);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { runWithLdapDisconnect } from './connectionGuard';
|
||||
import { log_debug, log_info, log_warn, log_error } from './logger';
|
||||
import { isAdminByGroups } from './adminGroups';
|
||||
import { requireUserCredentials } from './userCredentials';
|
||||
import { boundToAnotherEntry } from './accountBinding';
|
||||
|
||||
// Org/team sync is optional enrichment; failed sync must not block login.
|
||||
async function syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, userId) {
|
||||
@@ -148,15 +149,18 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
|
||||
|
||||
if (!user) {
|
||||
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
|
||||
// `username`, not `_id`: a user's _id is a random id, never the
|
||||
// username, so this query never found anybody and LDAP_EMAIL_MATCH_REQUIRE
|
||||
// made every existing user's login try to create the account again.
|
||||
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
username,
|
||||
'emails.0.address' : email,
|
||||
'emails.0.verified' : true
|
||||
};
|
||||
} else {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
username,
|
||||
'emails.0.address' : email
|
||||
};
|
||||
}
|
||||
@@ -194,6 +198,14 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
|
||||
|
||||
}
|
||||
|
||||
// A name or e-mail match that belongs to ANOTHER directory entry is not
|
||||
// this person's account (accountBinding.js): a reused login name must not
|
||||
// open the previous owner's boards.
|
||||
if (user && boundToAnotherEntry(user, Unique_Identifier_Field)) {
|
||||
log_warn('LDAP login matched an existing account by name or e-mail, but that account belongs to another directory entry; refusing', user._id);
|
||||
throw new Meteor.Error('LDAP-login-error', 'LDAP authentication failed');
|
||||
}
|
||||
|
||||
// Login user if they exist
|
||||
if (user) {
|
||||
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ALL_LDAP_USERS } from './userSearchValue';
|
||||
import { getLdapPhotoBuffer } from './ldapPhoto';
|
||||
import { ldapPresenceUpdate } from './presenceSync';
|
||||
import { isKnownLdapGroup } from './entryKind';
|
||||
import { boundToAnotherEntry } from './accountBinding';
|
||||
import {
|
||||
ldapTextValue,
|
||||
ldapTextValues,
|
||||
@@ -264,6 +265,23 @@ export function getDataToSyncUserData(ldapUser, user) {
|
||||
export async function syncUserData(user, ldapUser) {
|
||||
log_info('Syncing user data');
|
||||
log_debug('user', {'email': user.email, '_id': user._id});
|
||||
|
||||
// Record which directory entry this account belongs to. Only a NEW account
|
||||
// got its identifier (addLdapUser); an existing one kept whatever it had, so
|
||||
// accounts from before #4654 never got an idAttribute, and an account matched
|
||||
// by username after LDAP_UNIQUE_IDENTIFIER_FIELD changed never got the new
|
||||
// identifier - the login matched it by name every time. The login refuses an
|
||||
// account bound to another entry before it gets here (accountBinding.js).
|
||||
const uniqueId = getLdapUserUniqueID(ldapUser);
|
||||
const bound = (user && user.services && user.services.ldap) || {};
|
||||
if (user && user._id && uniqueId && uniqueId.value && (String(bound.id || '') !== String(uniqueId.value)
|
||||
|| String(bound.idAttribute || '').toLowerCase() !== String(uniqueId.attribute || '').toLowerCase())) {
|
||||
log_info('Recording the directory identifier of', user.username, ':', uniqueId.attribute);
|
||||
await Meteor.users.updateAsync({ _id: user._id }, { $set: {
|
||||
'services.ldap.id': uniqueId.value,
|
||||
'services.ldap.idAttribute': uniqueId.attribute,
|
||||
} });
|
||||
}
|
||||
// log_debug('ldapUser', ldapUser.object);
|
||||
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
@@ -451,6 +469,12 @@ export async function importNewUsers(ldap) {
|
||||
log_debug('userQuery merge', userQuery);
|
||||
|
||||
user = await Meteor.users.findOneAsync(userQuery);
|
||||
if (user && boundToAnotherEntry(user, uniqueId)) {
|
||||
// A reused login name: that account is another person's. Leave it,
|
||||
// and do not create a second account with the same username either.
|
||||
log_warn('LDAP import: username already belongs to another directory entry; skipping', username);
|
||||
continue;
|
||||
}
|
||||
if (user) {
|
||||
await syncUserData(user, ldapUser);
|
||||
}
|
||||
@@ -552,7 +576,17 @@ async function sync() {
|
||||
if (user.services && user.services.ldap && user.services.ldap.id) {
|
||||
ldapUser = await ldap.getUserById(user.services.ldap.id, user.services.ldap.idAttribute);
|
||||
} else {
|
||||
ldapUser = await ldap.getUserByUsername(user.username);
|
||||
// An account without an identifier is matched by its username. Two
|
||||
// directory entries answering to that name cannot be told apart:
|
||||
// syncing the first one would bind the account to it for good
|
||||
// (syncUserData stores its identifier). Skip the account instead -
|
||||
// neither updated nor disabled.
|
||||
try {
|
||||
ldapUser = await ldap.getUserByUsername(user.username, { unique: true });
|
||||
} catch (ambiguous) {
|
||||
log_warn(ambiguous && ambiguous.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ldapUser) {
|
||||
|
||||
@@ -64,7 +64,7 @@ Oidc.requestCredential = function (options, credentialRequestCompleteCallback) {
|
||||
first = false;
|
||||
}
|
||||
else {
|
||||
loginUrl += '&'
|
||||
loginUrl += '&';
|
||||
}
|
||||
loginUrl += encodeURIComponent(k) + '=' + encodeURIComponent(options[k]);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ OAuth.registerService('oidc', 2, null, async function (query) {
|
||||
// exist: a Nextcloud response with `ocs` but no `ocs.data` used to replace
|
||||
// userinfo with undefined and fail on the next line instead of this one.
|
||||
if (userinfo.ocs && userinfo.ocs.data) userinfo = userinfo.ocs.data; // Nextcloud hack
|
||||
if (userinfo.metadata) userinfo = userinfo.metadata // Openshift hack
|
||||
if (userinfo.metadata) userinfo = userinfo.metadata; // Openshift hack
|
||||
if (!userinfo || typeof userinfo !== 'object') {
|
||||
throw new Error('OIDC: the provider response unwrapped to nothing usable '
|
||||
+ '(ocs/metadata). Check the provider log.');
|
||||
@@ -447,7 +447,7 @@ var getTokenContent = function (token) {
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
};
|
||||
Meteor.methods({
|
||||
'groupRoutineOnLogin': async function(info, userId)
|
||||
{
|
||||
@@ -543,16 +543,16 @@ Meteor.methods({
|
||||
check(oidcUserId, String);
|
||||
|
||||
const defaultBoardParams = (process.env.DEFAULT_BOARD_ID || '').split(':');
|
||||
const defaultBoardId = defaultBoardParams.shift()
|
||||
if (!defaultBoardId) return
|
||||
const defaultBoardId = defaultBoardParams.shift();
|
||||
if (!defaultBoardId) return;
|
||||
|
||||
const board = await Boards.findOneAsync(defaultBoardId)
|
||||
const user = await Users.findOneAsync({ 'services.oidc.id': oidcUserId })
|
||||
const userId = user?._id
|
||||
const board = await Boards.findOneAsync(defaultBoardId);
|
||||
const user = await Users.findOneAsync({ 'services.oidc.id': oidcUserId });
|
||||
const userId = user?._id;
|
||||
const memberIndex = (board?.members || []).map(m => m.userId).indexOf(userId);
|
||||
if(!board || !userId || memberIndex > -1) return
|
||||
if(!board || !userId || memberIndex > -1) return;
|
||||
|
||||
await board.addMember(userId)
|
||||
await board.addMember(userId);
|
||||
// DEFAULT_BOARD_ID=<id>:isAdmin:isWorker... - Array has no .contains, so
|
||||
// this threw and the user got the default role whatever was configured.
|
||||
// isCommentsOnly is the spelling that has always been documented here;
|
||||
@@ -563,7 +563,7 @@ Meteor.methods({
|
||||
defaultBoardParams.includes("isNoComments"),
|
||||
defaultBoardParams.includes("isCommentOnly") || defaultBoardParams.includes("isCommentsOnly"),
|
||||
defaultBoardParams.includes("isWorker")
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v12.78</title>
|
||||
<title>WeKan REST API v12.85</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
|
||||
|
||||
<body>
|
||||
|
||||
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
|
||||
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
|
||||
<script type="module">
|
||||
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
|
||||
|
||||
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.78","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 `\u003cfield>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 global admin can call this. CountsLine truncated
|
||||
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.85","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 `\u003cfield>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 global admin can call this. CountsLine truncated
|
||||
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
|
||||
|
||||
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v12.78
|
||||
version: v12.85
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Reading the output: a type with a huge total and a huge count is many small
|
||||
objects, and the name column says what they are. On the run this was written
|
||||
for, 14.3 million IgnoreRule objects held 980 MB.
|
||||
"""
|
||||
import sys, json, collections, re
|
||||
import sys, json, collections
|
||||
|
||||
path = sys.argv[1]
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ def main():
|
||||
import httplib2
|
||||
transient.append(httplib2.HttpLib2Error)
|
||||
except ImportError:
|
||||
pass
|
||||
pass # httplib2 is optional: without it there is no extra transient error to retry
|
||||
apply_workarounds(RemoteBuildService, BadRequest, transient=tuple(transient))
|
||||
sys.argv[0] = "snapcraft"
|
||||
return snapcraft_main()
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 1278,
|
||||
appVersion = 1285,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "12.78~2026-11-02"),
|
||||
appMarketingVersion = (defaultText = "12.85~2026-11-02"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -5,18 +5,12 @@ import Attachments from '/models/attachments';
|
||||
import { fileStoreStrategyFactory } from '/models/attachments.server';
|
||||
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
||||
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ObjectId } from 'bson';
|
||||
import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
|
||||
import { tripCanary } from '/server/lib/canary';
|
||||
import { getFeatureFlags } from '/models/lib/featureFlags';
|
||||
import RecoveryEvents from '/models/recoveryEvents';
|
||||
import { recordRecoveryAudit } from '/server/lib/recoveryAudit';
|
||||
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
|
||||
import { softDeleteAttachment } from '/server/attachmentSoftDelete';
|
||||
import { assignedOnlyAttachmentScope, mayReadBoardAttachment, mayWriteCardAttachment } from '/server/lib/assignedOnlyAttachments';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
|
||||
const HARD_MAX_API_FILE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user