@antelopejs/dms 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/implementations/dms/page.d.ts +1 -0
  5. package/dist/implementations/dms/page.js +8 -1
  6. package/dist/implementations/dms/page.js.map +1 -1
  7. package/dist/implementations/dms/stale-session.d.ts +2 -0
  8. package/dist/implementations/dms/stale-session.js +16 -0
  9. package/dist/implementations/dms/stale-session.js.map +1 -0
  10. package/dist/implementations/dms-auth/index.d.ts +1 -0
  11. package/dist/implementations/dms-auth/index.js +19 -0
  12. package/dist/implementations/dms-auth/index.js.map +1 -1
  13. package/dist/routes/auth/client-ip.d.ts +7 -0
  14. package/dist/routes/auth/client-ip.js +42 -0
  15. package/dist/routes/auth/client-ip.js.map +1 -0
  16. package/dist/routes/auth/index.d.ts +1 -0
  17. package/dist/routes/auth/index.js +1 -0
  18. package/dist/routes/auth/index.js.map +1 -1
  19. package/dist/routes/auth.d.ts +3 -0
  20. package/dist/routes/auth.js +15 -4
  21. package/dist/routes/auth.js.map +1 -1
  22. package/dist/test/integration/auth/client-ip.test.d.ts +1 -0
  23. package/dist/test/integration/auth/client-ip.test.js +14 -0
  24. package/dist/test/integration/auth/client-ip.test.js.map +1 -0
  25. package/dist/test/integration/page-session.test.d.ts +1 -0
  26. package/dist/test/integration/page-session.test.js +52 -0
  27. package/dist/test/integration/page-session.test.js.map +1 -0
  28. package/dist/test/unit/client-ip.test.d.ts +1 -0
  29. package/dist/test/unit/client-ip.test.js +77 -0
  30. package/dist/test/unit/client-ip.test.js.map +1 -0
  31. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.d.ts +1 -0
  32. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js +96 -0
  33. package/dist/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.js.map +1 -0
  34. package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -2
  35. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +27 -2
  36. package/frontend-vue/layers/dms-core/app/utils/menu.ts +26 -10
  37. package/frontend-vue/layers/dms-core/app/utils/routePath.ts +23 -0
  38. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +3 -0
  39. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +3 -0
  40. package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +4 -1
  41. package/frontend-vue/layers/dms-layout/app/app.config.ts +9 -1
  42. package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -5
  43. package/frontend-vue/layers/dms-layout/app/components/profile/ProfileLanguage.vue +3 -23
  44. package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +33 -0
  45. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +2 -1
  46. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +31 -3
  47. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/relationBadges.ts +36 -0
  48. package/frontend-vue/layers/dms-ui/app/build/types/tree.ts +5 -10
  49. package/frontend-vue/tests/api-error-title.test.ts +24 -0
  50. package/frontend-vue/tests/auth-middleware-error.test.ts +58 -0
  51. package/frontend-vue/tests/button-disabled-theme.test.ts +21 -0
  52. package/frontend-vue/tests/menu-navigation.test.ts +39 -10
  53. package/frontend-vue/tests/relation-badges.test.ts +32 -0
  54. package/frontend-vue/tests/user-language.test.ts +58 -0
  55. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const client_ip_1 = require("../../routes/auth/client-ip");
5
+ const SOCKET_ADDRESS = "10.0.0.9";
6
+ const CLIENT_ADDRESS = "109.132.199.8";
7
+ const FRONTEND_EGRESS = "195.201.71.66";
8
+ const CDN_EDGE = "104.23.166.64";
9
+ const STAGING_CHAIN = `${CLIENT_ADDRESS},${FRONTEND_EGRESS},${CDN_EDGE}`;
10
+ function authConfig(overrides) {
11
+ return {
12
+ jwtSecret: "",
13
+ emailValidationTokenLifetime: 0,
14
+ passwordRecoverTokenLifetime: 0,
15
+ accessTokenLifetime: 0,
16
+ refreshTokenLifetime: 0,
17
+ userSensitiveKeys: [],
18
+ mustValidateEmail: false,
19
+ ...overrides,
20
+ };
21
+ }
22
+ describe("[unit] resolveClientIp", () => {
23
+ it("records the socket peer and ignores the header without trusted proxies", () => {
24
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: SOCKET_ADDRESS, forwardedFor: STAGING_CHAIN }, 0)).to.equal(SOCKET_ADDRESS);
25
+ });
26
+ it("walks back one entry per trusted proxy from the right", () => {
27
+ const source = {
28
+ socketAddress: SOCKET_ADDRESS,
29
+ forwardedFor: STAGING_CHAIN,
30
+ };
31
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)(source, 1)).to.equal(CDN_EDGE);
32
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)(source, 2)).to.equal(FRONTEND_EGRESS);
33
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)(source, 3)).to.equal(CLIENT_ADDRESS);
34
+ });
35
+ it("never reaches the entries a client wrote ahead of the trusted ones", () => {
36
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({
37
+ socketAddress: SOCKET_ADDRESS,
38
+ forwardedFor: `6.6.6.6, 7.7.7.7, ${CLIENT_ADDRESS}`,
39
+ }, 1)).to.equal(CLIENT_ADDRESS);
40
+ });
41
+ it("yields the leftmost entry of a chain shorter than configured", () => {
42
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: SOCKET_ADDRESS, forwardedFor: CLIENT_ADDRESS }, 3)).to.equal(CLIENT_ADDRESS);
43
+ });
44
+ it("falls back to the socket peer without a header", () => {
45
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: SOCKET_ADDRESS, forwardedFor: undefined }, 2)).to.equal(SOCKET_ADDRESS);
46
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: SOCKET_ADDRESS, forwardedFor: " , " }, 2)).to.equal(SOCKET_ADDRESS);
47
+ });
48
+ it("returns an empty string when nothing is known", () => {
49
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: undefined, forwardedFor: undefined }, 1)).to.equal("");
50
+ });
51
+ it("keeps IPv6 addresses whole and trims the separators", () => {
52
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: "::1", forwardedFor: " 2001:db8::1 , fd00::2 " }, 2)).to.equal("2001:db8::1");
53
+ });
54
+ it("unmaps IPv4-mapped IPv6 addresses", () => {
55
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: "::ffff:127.0.0.1", forwardedFor: undefined }, 0)).to.equal("127.0.0.1");
56
+ (0, chai_1.expect)((0, client_ip_1.resolveClientIp)({ socketAddress: "::ffff:abcd", forwardedFor: "" }, 0)).to.equal("::ffff:abcd");
57
+ });
58
+ });
59
+ describe("[unit] trustedProxyCount", () => {
60
+ it("trusts no proxy by default", () => {
61
+ (0, chai_1.expect)((0, client_ip_1.trustedProxyCount)(authConfig({}))).to.equal(0);
62
+ });
63
+ it("reads trustedProxies", () => {
64
+ (0, chai_1.expect)((0, client_ip_1.trustedProxyCount)(authConfig({ trustedProxies: 3 }))).to.equal(3);
65
+ });
66
+ it("maps the deprecated oauth.trustProxy to one hop", () => {
67
+ (0, chai_1.expect)((0, client_ip_1.trustedProxyCount)(authConfig({ oauth: { trustProxy: true } }))).to.equal(1);
68
+ });
69
+ it("prefers trustedProxies over oauth.trustProxy", () => {
70
+ (0, chai_1.expect)((0, client_ip_1.trustedProxyCount)(authConfig({ trustedProxies: 0, oauth: { trustProxy: true } }))).to.equal(0);
71
+ });
72
+ it("ignores a value that is not a non-negative integer", () => {
73
+ (0, chai_1.expect)((0, client_ip_1.trustedProxyCount)(authConfig({ trustedProxies: -1 }))).to.equal(0);
74
+ (0, chai_1.expect)((0, client_ip_1.trustedProxyCount)(authConfig({ trustedProxies: 1.5 }))).to.equal(0);
75
+ });
76
+ });
77
+ //# sourceMappingURL=client-ip.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.test.js","sourceRoot":"","sources":["../../../src/test/unit/client-ip.test.ts"],"names":[],"mappings":";;AAAA,+BAA8B;AAE9B,2DAGqC;AAErC,MAAM,cAAc,GAAG,UAAU,CAAC;AAClC,MAAM,cAAc,GAAG,eAAe,CAAC;AACvC,MAAM,eAAe,GAAG,eAAe,CAAC;AACxC,MAAM,QAAQ,GAAG,eAAe,CAAC;AACjC,MAAM,aAAa,GAAG,GAAG,cAAc,IAAI,eAAe,IAAI,QAAQ,EAAE,CAAC;AAEzE,SAAS,UAAU,CAAC,SAA8B;IAChD,OAAO;QACL,SAAS,EAAE,EAAE;QACb,4BAA4B,EAAE,CAAC;QAC/B,4BAA4B,EAAE,CAAC;QAC/B,mBAAmB,EAAE,CAAC;QACtB,oBAAoB,EAAE,CAAC;QACvB,iBAAiB,EAAE,EAAE;QACrB,iBAAiB,EAAE,KAAK;QACxB,GAAG,SAAS;KACb,CAAC;AACJ,CAAC;AAED,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,wEAAwE,EAAE,GAAG,EAAE;QAChF,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,aAAa,EAAE,EAC9D,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uDAAuD,EAAE,GAAG,EAAE;QAC/D,MAAM,MAAM,GAAG;YACb,aAAa,EAAE,cAAc;YAC7B,YAAY,EAAE,aAAa;SAC5B,CAAC;QACF,IAAA,aAAM,EAAC,IAAA,2BAAe,EAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACtD,IAAA,aAAM,EAAC,IAAA,2BAAe,EAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;QAC7D,IAAA,aAAM,EAAC,IAAA,2BAAe,EAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oEAAoE,EAAE,GAAG,EAAE;QAC5E,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb;YACE,aAAa,EAAE,cAAc;YAC7B,YAAY,EAAE,qBAAqB,cAAc,EAAE;SACpD,EACD,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8DAA8D,EAAE,GAAG,EAAE;QACtE,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,EAC/D,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,SAAS,EAAE,EAC1D,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;QAC3B,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,KAAK,EAAE,EACtD,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,IAAA,aAAM,EACJ,IAAA,2BAAe,EAAC,EAAE,aAAa,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,CAC1E,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IACjB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY,EAAE,yBAAyB,EAAE,EACjE,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,IAAA,aAAM,EACJ,IAAA,2BAAe,EACb,EAAE,aAAa,EAAE,kBAAkB,EAAE,YAAY,EAAE,SAAS,EAAE,EAC9D,CAAC,CACF,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QACxB,IAAA,aAAM,EACJ,IAAA,2BAAe,EAAC,EAAE,aAAa,EAAE,aAAa,EAAE,YAAY,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,CACvE,CAAC,EAAE,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IAC5B,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,0BAA0B,EAAE,GAAG,EAAE;IACxC,EAAE,CAAC,4BAA4B,EAAE,GAAG,EAAE;QACpC,IAAA,aAAM,EAAC,IAAA,6BAAiB,EAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sBAAsB,EAAE,GAAG,EAAE;QAC9B,IAAA,aAAM,EAAC,IAAA,6BAAiB,EAAC,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC3E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,IAAA,aAAM,EACJ,IAAA,6BAAiB,EAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,CAC/D,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAChB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACtD,IAAA,aAAM,EACJ,IAAA,6BAAiB,EACf,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,CAAC,CAC/D,CACF,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAChB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,IAAA,aAAM,EAAC,IAAA,6BAAiB,EAAC,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAC1E,IAAA,aAAM,EAAC,IAAA,6BAAiB,EAAC,UAAU,CAAC,EAAE,cAAc,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC7E,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const interface_api_1 = require("@antelopejs/interface-api");
10
+ const interface_data_api_1 = require("@antelopejs/interface-data-api");
11
+ const interface_database_decorators_1 = require("@antelopejs/interface-database-decorators");
12
+ const chai_1 = require("chai");
13
+ const constants_1 = require("@antelopejs/interface-dms/constants");
14
+ const table_view_1 = require("@antelopejs/interface-dms/base/table-view");
15
+ const logging_1 = require("../../../helpers/logging");
16
+ const TABLE = "tab-count-warning-rows";
17
+ const MISSING_ROUTE = "count/batch";
18
+ const TABS = [
19
+ {
20
+ id: "open",
21
+ label: "Open",
22
+ filters: [{ accessorKey: "status", value: "open", mode: "is" }],
23
+ },
24
+ ];
25
+ let Row = class Row extends interface_database_decorators_1.Table {
26
+ };
27
+ Row = __decorate([
28
+ (0, interface_database_decorators_1.RegisterTable)(TABLE, constants_1.CORE_SCHEMA_NAME)
29
+ ], Row);
30
+ let CountOnlyAPI = class CountOnlyAPI extends (0, interface_data_api_1.DataController)(Row, { list: table_view_1.TableViewRoutes.List, count: table_view_1.TableViewRoutes.Count }, (0, interface_api_1.Controller)("/api/tab-count-warning/count-only")) {
31
+ };
32
+ CountOnlyAPI = __decorate([
33
+ (0, interface_data_api_1.RegisterDataController)()
34
+ ], CountOnlyAPI);
35
+ let RepeatedAPI = class RepeatedAPI extends (0, interface_data_api_1.DataController)(Row, { list: table_view_1.TableViewRoutes.List }, (0, interface_api_1.Controller)("/api/tab-count-warning/repeated")) {
36
+ };
37
+ RepeatedAPI = __decorate([
38
+ (0, interface_data_api_1.RegisterDataController)()
39
+ ], RepeatedAPI);
40
+ let BatchAPI = class BatchAPI extends (0, interface_data_api_1.DataController)(Row, { list: table_view_1.TableViewRoutes.List, countBatch: table_view_1.TableViewRoutes.CountBatch }, (0, interface_api_1.Controller)("/api/tab-count-warning/batch")) {
41
+ };
42
+ BatchAPI = __decorate([
43
+ (0, interface_data_api_1.RegisterDataController)()
44
+ ], BatchAPI);
45
+ let RenamedBatchAPI = class RenamedBatchAPI extends (0, interface_data_api_1.DataController)(Row, { list: table_view_1.TableViewRoutes.List, tabCounters: table_view_1.TableViewRoutes.CountBatch }, (0, interface_api_1.Controller)("/api/tab-count-warning/renamed")) {
46
+ };
47
+ RenamedBatchAPI = __decorate([
48
+ (0, interface_data_api_1.RegisterDataController)()
49
+ ], RenamedBatchAPI);
50
+ let NoTabsAPI = class NoTabsAPI extends (0, interface_data_api_1.DataController)(Row, { list: table_view_1.TableViewRoutes.List }, (0, interface_api_1.Controller)("/api/tab-count-warning/no-tabs")) {
51
+ };
52
+ NoTabsAPI = __decorate([
53
+ (0, interface_data_api_1.RegisterDataController)()
54
+ ], NoTabsAPI);
55
+ function countBatchWarnings(register) {
56
+ const captured = (0, logging_1.captureWarnings)();
57
+ try {
58
+ register();
59
+ }
60
+ finally {
61
+ captured.restore();
62
+ }
63
+ return captured.messages.filter((message) => message.includes(MISSING_ROUTE));
64
+ }
65
+ describe("[unit] interfaces/dms-base/table-view — filter tabs require countBatch", () => {
66
+ it("warns when a table with tabs is mounted on a controller without countBatch", () => {
67
+ const warnings = countBatchWarnings(() => (0, table_view_1.TableView)(CountOnlyAPI, { tabs: TABS, realtime: false }));
68
+ (0, chai_1.expect)(warnings).to.have.length(1);
69
+ (0, chai_1.expect)(warnings[0]).to.include("CountOnlyAPI");
70
+ (0, chai_1.expect)(warnings[0]).to.include("/api/tab-count-warning/count-only");
71
+ (0, chai_1.expect)(warnings[0]).to.include("TableViewRoutes.CountBatch");
72
+ });
73
+ it("warns once per controller however many tables mount it", () => {
74
+ const warnings = countBatchWarnings(() => {
75
+ (0, table_view_1.TableView)(RepeatedAPI, { tabs: TABS, realtime: false });
76
+ (0, table_view_1.TableView)(RepeatedAPI, { tabs: TABS, realtime: false });
77
+ });
78
+ (0, chai_1.expect)(warnings).to.have.length(1);
79
+ });
80
+ it("stays silent when the controller mounts countBatch", () => {
81
+ const warnings = countBatchWarnings(() => (0, table_view_1.TableView)(BatchAPI, { tabs: TABS, realtime: false }));
82
+ (0, chai_1.expect)(warnings).to.be.empty;
83
+ });
84
+ it("stays silent when countBatch is mounted under another key", () => {
85
+ const warnings = countBatchWarnings(() => (0, table_view_1.TableView)(RenamedBatchAPI, { tabs: TABS, realtime: false }));
86
+ (0, chai_1.expect)(warnings).to.be.empty;
87
+ });
88
+ it("stays silent when the table declares no tabs", () => {
89
+ const warnings = countBatchWarnings(() => {
90
+ (0, table_view_1.TableView)(NoTabsAPI, { realtime: false });
91
+ (0, table_view_1.TableView)(NoTabsAPI, { tabs: [], realtime: false });
92
+ });
93
+ (0, chai_1.expect)(warnings).to.be.empty;
94
+ });
95
+ });
96
+ //# sourceMappingURL=table-view-tab-count-warning.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"table-view-tab-count-warning.test.js","sourceRoot":"","sources":["../../../../../src/test/unit/interfaces/dms-base/table-view-tab-count-warning.test.ts"],"names":[],"mappings":"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"}
@@ -72,8 +72,11 @@ async function isPublicAccessRoute(to: ReturnType<typeof useDmsRoute>) {
72
72
  export default defineDmsMiddleware(async (to) => {
73
73
  if (to.matched.length === 0) return;
74
74
 
75
- const error = useError();
76
- if (error.value) throw showError(error.value);
75
+ // An error the page already raised (a failed `validation`, a missing page)
76
+ // is committed and rendered with its own status. Re-throwing it here would
77
+ // reject the pending navigation the SSR render awaits, turning that 404 into
78
+ // a 500 fallback render.
79
+ if (useError().value) return abortNavigation();
77
80
 
78
81
  const options = to.meta.auth as MiddlewareMeta | undefined;
79
82
  if (!options) return;
@@ -6,12 +6,16 @@ interface Options {
6
6
 
7
7
  interface ApiError {
8
8
  statusCode?: number;
9
+ status?: number;
9
10
  message?: string;
10
11
  data?: unknown;
11
12
  }
12
13
 
13
- const DEFAULT_TITLE_KEY = "error.500.title";
14
+ const SERVER_ERROR_TITLE_KEY = "error.500.title";
15
+ const CLIENT_ERROR_TITLE_KEY = "error.request_refused.title";
14
16
  const FALLBACK_ERROR_KEY = "error.500.description";
17
+ const CLIENT_ERROR_STATUS_MIN = 400;
18
+ const CLIENT_ERROR_STATUS_MAX = 499;
15
19
 
16
20
  function isApiError(error: unknown): error is ApiError {
17
21
  return typeof error === "object" && error !== null;
@@ -32,6 +36,27 @@ function resolveDescription(error: ApiError): string {
32
36
  return FALLBACK_ERROR_KEY;
33
37
  }
34
38
 
39
+ function isClientErrorStatus(status: number | undefined): boolean {
40
+ return (
41
+ status !== undefined &&
42
+ status >= CLIENT_ERROR_STATUS_MIN &&
43
+ status <= CLIENT_ERROR_STATUS_MAX
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The i18n title key matching a fetch error's HTTP status: a 4xx is a request
49
+ * the backend refused, anything else (5xx, network failure) a server error.
50
+ */
51
+ export function resolveApiErrorTitle(error: unknown): string {
52
+ const status = isApiError(error)
53
+ ? (error.statusCode ?? error.status)
54
+ : undefined;
55
+ return isClientErrorStatus(status)
56
+ ? CLIENT_ERROR_TITLE_KEY
57
+ : SERVER_ERROR_TITLE_KEY;
58
+ }
59
+
35
60
  /**
36
61
  * Extract the backend's error body from a fetch error — an i18n message key
37
62
  * per the API error contract — falling back to the generic 500 key.
@@ -46,7 +71,7 @@ export function useApiError(error: unknown, options?: Options) {
46
71
  const { t } = dmsApp.$i18n;
47
72
  const translate = (message: string) => resolveApiMessage(t, message);
48
73
 
49
- const title = options?.title || DEFAULT_TITLE_KEY;
74
+ const title = options?.title || resolveApiErrorTitle(error);
50
75
  const description = options?.description || resolveApiErrorMessage(error);
51
76
 
52
77
  toast.add({
@@ -1,4 +1,5 @@
1
1
  import type { NavigationMenuItem } from "@nuxt/ui";
2
+ import { parseLinkQuery, stripQueryAndHash } from "./routePath";
2
3
 
3
4
  // Menu items carry the registered page/category's dot-separated `fullId`
4
5
  // (e.g. `pages.erp.orders`) plus the DMS semantic fields; Nuxt UI's
@@ -88,7 +89,9 @@ const matchesRoutePrefix = (currentPath: string, to: string): boolean => {
88
89
  const resolveItemTarget = (
89
90
  to: NavigationMenuItem["to"],
90
91
  ): MenuItemTarget | null => {
91
- if (typeof to === "string") return { path: to };
92
+ if (typeof to === "string") {
93
+ return { path: stripQueryAndHash(to), query: parseLinkQuery(to) };
94
+ }
92
95
  const target = to as MenuItemTarget | undefined;
93
96
  if (target && typeof target.path === "string") {
94
97
  return { path: target.path, query: target.query };
@@ -96,9 +99,11 @@ const resolveItemTarget = (
96
99
  return null;
97
100
  };
98
101
 
99
- // Subset match, mirroring the `exactQuery: "partial"` the entries are built
100
- // with: the entry's own parameters must be the current ones, while unrelated
101
- // parameters the page adds (tabs, filters) are ignored.
102
+ const hasQuery = (target: MenuItemTarget | null): boolean =>
103
+ Object.keys(target?.query ?? {}).length > 0;
104
+
105
+ // Subset match: the entry's own parameters must be the current ones, while
106
+ // unrelated parameters the page adds (tabs, filters) are ignored.
102
107
  const queryMatches = (
103
108
  declared: MenuItemTarget["query"],
104
109
  currentQuery: Record<string, unknown>,
@@ -133,18 +138,29 @@ export const isMenuItemActive = (
133
138
  return matchedFullId !== null && fullId === matchedFullId;
134
139
  };
135
140
 
136
- // Only the boolean `true` is set: Nuxt UI forwards an explicit `active` to the
137
- // underlying link, and a `false` there would suppress the built-in route match.
138
- // Leaving it undefined preserves that native highlighting for every other item.
141
+ // Only the boolean `true` is set on plain entries: Nuxt UI forwards an explicit
142
+ // `active` to the underlying link, and a `false` there would suppress the
143
+ // built-in route match. Entries carrying a query always get an explicit value,
144
+ // because that built-in match is a URL prefix test that would light up
145
+ // `?project=a` on `?project=ab`.
146
+ const resolveActiveState = (
147
+ item: NavigationMenuItem,
148
+ matchedFullId: string | null,
149
+ currentRoute: MenuRouteState,
150
+ ): Partial<Pick<NavigationMenuItem, "active">> => {
151
+ if (isMenuItemActive(item, matchedFullId, currentRoute)) {
152
+ return { active: true };
153
+ }
154
+ return hasQuery(resolveItemTarget(item.to)) ? { active: false } : {};
155
+ };
156
+
139
157
  export const addActiveStateToMenuItem = (
140
158
  item: NavigationMenuItem,
141
159
  matchedFullId: string | null,
142
160
  currentRoute: MenuRouteState,
143
161
  ): NavigationMenuItem => ({
144
162
  ...item,
145
- ...(isMenuItemActive(item, matchedFullId, currentRoute)
146
- ? { active: true }
147
- : {}),
163
+ ...resolveActiveState(item, matchedFullId, currentRoute),
148
164
  children: item.children?.map((child) =>
149
165
  addActiveStateToMenuItem(child, matchedFullId, currentRoute),
150
166
  ),
@@ -9,3 +9,26 @@ export function stripQueryAndHash(link: string): string {
9
9
  const [path] = link.split(QUERY_OR_HASH);
10
10
  return path ?? link;
11
11
  }
12
+
13
+ /**
14
+ * A link to `path` narrowed by `query`, as the single string every link
15
+ * renderer accepts: the Inertia-backed `ULink` only takes a string `to`, and
16
+ * stringifies an object into `[object Object]`.
17
+ */
18
+ export function buildLinkWithQuery(
19
+ path: string,
20
+ query: Record<string, string> | undefined,
21
+ ): string {
22
+ const search = new URLSearchParams(query ?? {}).toString();
23
+ return search === "" ? path : `${path}?${search}`;
24
+ }
25
+
26
+ /** The query parameters a link carries, the inverse of `buildLinkWithQuery`. */
27
+ export function parseLinkQuery(link: string): Record<string, string> {
28
+ const [withoutHash = ""] = link.split("#");
29
+ const queryStart = withoutHash.indexOf("?");
30
+ if (queryStart === -1) return {};
31
+ return Object.fromEntries(
32
+ new URLSearchParams(withoutHash.slice(queryStart + 1)),
33
+ );
34
+ }
@@ -37,6 +37,9 @@
37
37
  "title": "Server error",
38
38
  "description": "An unexpected error occurred. Please try again later."
39
39
  },
40
+ "request_refused": {
41
+ "title": "Request refused"
42
+ },
40
43
  "recovering": {
41
44
  "title": "Restoring your session...",
42
45
  "description": "Your session has expired. Refreshing your access and reloading the page."
@@ -37,6 +37,9 @@
37
37
  "title": "Erreur serveur",
38
38
  "description": "Une erreur inattendue s'est produite. Veuillez réessayer plus tard."
39
39
  },
40
+ "request_refused": {
41
+ "title": "Requête refusée"
42
+ },
40
43
  "recovering": {
41
44
  "title": "Restauration de votre session...",
42
45
  "description": "Votre session a expiré. Renouvellement de l'accès et rechargement de la page."
@@ -43,7 +43,10 @@ interface DmsPrivateConfig {
43
43
  relaySecret: string;
44
44
  /**
45
45
  * Whether a trusted reverse proxy sits immediately in front and appends
46
- * to `x-forwarded-for`; drives the OAuth rate-limit client key.
46
+ * to `x-forwarded-for`.
47
+ *
48
+ * @deprecated Mirrors the deprecated `auth.oauth.trustProxy`. The frontend
49
+ * server keys its OAuth rate limit on its own `DMS_TRUSTED_PROXY_HOPS`.
47
50
  */
48
51
  trustProxy?: boolean;
49
52
  };
@@ -134,7 +134,15 @@ export default {
134
134
  button: {
135
135
  slots: {
136
136
  // Tactile press + full transition (design .btn active:scale(.97)).
137
- base: "transition active:scale-[0.98]",
137
+ // Nuxt UI only fades a disabled button to 75%, which still reads as
138
+ // enabled on a filled primary: fade it further and drop the press and
139
+ // the inset highlight so every variant looks inert.
140
+ base: [
141
+ "transition active:scale-[0.98]",
142
+ "disabled:opacity-50 aria-disabled:opacity-50",
143
+ "disabled:active:scale-100 aria-disabled:active:scale-100",
144
+ "disabled:shadow-none aria-disabled:shadow-none",
145
+ ].join(" "),
138
146
  },
139
147
  compoundVariants: [
140
148
  // Inset top highlight on the solid primary fill (design .btn-primary).
@@ -82,11 +82,6 @@ function onLinkHover(): void {
82
82
  const { to } = props.item;
83
83
  if (typeof to === "string") {
84
84
  prefetchPageLayout(stripQueryAndHash(to));
85
- return;
86
- }
87
- const path = (to as MenuItemTarget | undefined)?.path;
88
- if (path) {
89
- prefetchPageLayout(path);
90
85
  }
91
86
  }
92
87
  </script>
@@ -1,8 +1,7 @@
1
1
  <script setup lang="ts">
2
- const { locale, setLocale } = useI18n();
2
+ const { locale } = useI18n();
3
3
  const { uniqueLocales } = useUniqueLocales();
4
- const { $authFetch } = useAuthFetch();
5
- const { user, fetch: refreshUser } = useUserSession();
4
+ const { changeLanguage } = useUserLanguage();
6
5
 
7
6
  const languageOptions = computed(() =>
8
7
  uniqueLocales.value.map((lang) => ({
@@ -10,25 +9,6 @@ const languageOptions = computed(() =>
10
9
  value: lang.code,
11
10
  })),
12
11
  );
13
-
14
- const handleLanguageChange = async (newLanguage: "en" | "fr") => {
15
- setLocale(newLanguage);
16
-
17
- try {
18
- await $authFetch("/settings/user/profile", {
19
- method: "POST",
20
- body: {
21
- name: user.value?.name,
22
- email: user.value?.email,
23
- language: newLanguage,
24
- },
25
- });
26
-
27
- await refreshUser();
28
- } catch {
29
- /* ignore — silently skip the update on network / backend errors */
30
- }
31
- };
32
12
  </script>
33
13
 
34
14
  <template>
@@ -62,7 +42,7 @@ const handleLanguageChange = async (newLanguage: "en" | "fr") => {
62
42
  class="w-full"
63
43
  :items="languageOptions"
64
44
  :model-value="locale"
65
- @update:model-value="handleLanguageChange"
45
+ @update:model-value="changeLanguage"
66
46
  />
67
47
  </div>
68
48
  </section>
@@ -0,0 +1,33 @@
1
+ const PROFILE_ENDPOINT = "/settings/user/profile";
2
+
3
+ /**
4
+ * Switches the interface language and stores it on the user's profile, so the
5
+ * `language-sync` plugin re-applies it on the next load instead of reverting
6
+ * to the previously stored language. Every language switcher goes through it.
7
+ */
8
+ export function useUserLanguage() {
9
+ const dmsApp = useDmsApp();
10
+ const { $authFetch } = useAuthFetch();
11
+ const { user, fetch: refreshUser } = useUserSession();
12
+
13
+ async function changeLanguage(language: string): Promise<void> {
14
+ await dmsApp.$i18n.setLocale(language);
15
+
16
+ try {
17
+ await $authFetch(PROFILE_ENDPOINT, {
18
+ method: "POST",
19
+ body: {
20
+ name: user.value?.name,
21
+ email: user.value?.email,
22
+ language,
23
+ },
24
+ });
25
+
26
+ await refreshUser();
27
+ } catch {
28
+ /* ignore — silently skip the update on network / backend errors */
29
+ }
30
+ }
31
+
32
+ return { changeLanguage };
33
+ }
@@ -34,6 +34,7 @@ export default defineDmsPlugin(() => {
34
34
  const { i18n, translate } = getPluginI18n();
35
35
  const toast = useToast();
36
36
  const { logout } = useLogout();
37
+ const { changeLanguage } = useUserLanguage();
37
38
  const { copy } = useClipboard({ legacy: true });
38
39
 
39
40
  const localeItems = (): CommandPaletteItem[] =>
@@ -41,7 +42,7 @@ export default defineDmsPlugin(() => {
41
42
  label: locale.name ?? locale.code,
42
43
  active: i18n.locale.value === locale.code,
43
44
  onSelect: () => {
44
- void i18n.setLocale(locale.code);
45
+ void changeLanguage(locale.code);
45
46
  },
46
47
  }));
47
48
 
@@ -1,8 +1,10 @@
1
1
  import { defineAsyncComponent, type VNode } from "vue";
2
2
  import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
3
3
  import UAvatar from "@nuxt/ui/components/Avatar.vue";
4
+ import UBadge from "@nuxt/ui/components/Badge.vue";
4
5
  import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
5
6
  import ULink from "@nuxt/ui/components/Link.vue";
7
+ import { buildRelationBadges } from "./relationBadges";
6
8
 
7
9
  const FilePreview = defineAsyncComponent(
8
10
  () => import("../../../components/table-view/FilePreview.vue"),
@@ -37,6 +39,9 @@ const LINK_CLASS =
37
39
  const LINK_ICON_CLASS = "size-3 text-primary/70 flex-shrink-0";
38
40
  const LINK_ICON_NAME = "i-ph-arrow-up-right-light";
39
41
  const DEFAULT_RELATION_VALUE_KEY = "_id";
42
+ const DEFAULT_RELATION_LABEL_KEY = "name";
43
+ const RELATION_BADGES_CLASS = "inline-flex flex-wrap items-center gap-1";
44
+ const RELATION_BADGE_CLASS = "max-w-40";
40
45
  const PERMISSION_SEPARATOR = ".";
41
46
 
42
47
  interface LinkRendererOptions {
@@ -263,18 +268,41 @@ function countLeafPermissions(value: string[]): number {
263
268
  return value.filter((id) => !parentIds.has(id)).length;
264
269
  }
265
270
 
271
+ function renderRelationBadge(label: string, title?: string) {
272
+ return h(UBadge, {
273
+ label,
274
+ title: title ?? label,
275
+ color: "neutral",
276
+ variant: "subtle",
277
+ size: Size.small,
278
+ class: RELATION_BADGE_CLASS,
279
+ });
280
+ }
281
+
282
+ function renderRelationBadges(values: unknown[], labelKey: string) {
283
+ const { processI18n } = useTranslation();
284
+ const { visible, hidden } = buildRelationBadges(values, labelKey);
285
+ const badges = visible.map((label) =>
286
+ renderRelationBadge(processI18n(label)),
287
+ );
288
+ if (hidden.length > 0) {
289
+ const hiddenLabels = hidden.map((label) => processI18n(label)).join(", ");
290
+ badges.push(renderRelationBadge(`+${hidden.length}`, hiddenLabels));
291
+ }
292
+ return h("span", { class: RELATION_BADGES_CLASS }, badges);
293
+ }
294
+
266
295
  function renderRelation(value: unknown, options: unknown) {
267
- const { t } = useI18n();
268
296
  const opts = options as RelationOptions | undefined;
297
+ const labelKey = opts?.keyMapping?.label || DEFAULT_RELATION_LABEL_KEY;
269
298
 
270
299
  if (opts?.multiple && Array.isArray(value)) {
271
- return t("dms.form.relation.selected_count", { count: value.length });
300
+ return renderRelationBadges(value, labelKey);
272
301
  }
273
302
 
274
303
  if (!value || !isObject(value)) return value;
275
304
 
276
305
  const valueObject = value as Record<string, string>;
277
- const labelKey = opts?.keyMapping?.label || "name";
278
306
  const avatarKey = opts?.keyMapping?.avatar;
279
307
  const label = valueObject[labelKey] || String(value);
280
308
 
@@ -0,0 +1,36 @@
1
+ /** How many labels a multi-value relation cell shows before collapsing. */
2
+ export const MAX_RELATION_BADGES = 3;
3
+
4
+ /** The labels a multi-value cell shows, and how many it folds into `+N`. */
5
+ export interface RelationBadges {
6
+ visible: string[];
7
+ hidden: string[];
8
+ }
9
+
10
+ function readRelationLabel(item: unknown, labelKey: string): string {
11
+ if (typeof item === "object" && item !== null) {
12
+ const label = (item as Record<string, unknown>)[labelKey];
13
+ return label === undefined || label === null ? "" : String(label);
14
+ }
15
+ return item === undefined || item === null ? "" : String(item);
16
+ }
17
+
18
+ /**
19
+ * Split a multi-value relation into the labels rendered as badges and the ones
20
+ * folded into a trailing `+N`. List rows carry the related rows resolved by the
21
+ * relation join, so each item is read through the `label` key mapping; a raw id
22
+ * (join skipped) is shown as is rather than dropped.
23
+ */
24
+ export function buildRelationBadges(
25
+ values: unknown[],
26
+ labelKey: string,
27
+ maxVisible = MAX_RELATION_BADGES,
28
+ ): RelationBadges {
29
+ const labels = values
30
+ .map((item) => readRelationLabel(item, labelKey))
31
+ .filter((label) => label !== "");
32
+ return {
33
+ visible: labels.slice(0, maxVisible),
34
+ hidden: labels.slice(maxVisible),
35
+ };
36
+ }