@things-factory/auth-base 10.1.213 → 10.1.218

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 (114) hide show
  1. package/config/config.development.js +2 -4
  2. package/config/config.production.js +1 -2
  3. package/dist-client/actions/auth.d.ts +1 -0
  4. package/dist-client/actions/auth.js +6 -0
  5. package/dist-client/actions/auth.js.map +1 -1
  6. package/dist-client/reducers/auth.d.ts +8 -0
  7. package/dist-client/reducers/auth.js +8 -2
  8. package/dist-client/reducers/auth.js.map +1 -1
  9. package/dist-client/tsconfig.tsbuildinfo +1 -1
  10. package/dist-server/constants/error-code.d.ts +1 -0
  11. package/dist-server/constants/error-code.js +2 -1
  12. package/dist-server/constants/error-code.js.map +1 -1
  13. package/dist-server/controllers/auth.d.ts +0 -1
  14. package/dist-server/controllers/auth.js +0 -1
  15. package/dist-server/controllers/auth.js.map +1 -1
  16. package/dist-server/controllers/password-setup.d.ts +33 -0
  17. package/dist-server/controllers/password-setup.js +77 -0
  18. package/dist-server/controllers/password-setup.js.map +1 -0
  19. package/dist-server/controllers/reset-password.d.ts +8 -1
  20. package/dist-server/controllers/reset-password.js +21 -11
  21. package/dist-server/controllers/reset-password.js.map +1 -1
  22. package/dist-server/controllers/signin.js +10 -0
  23. package/dist-server/controllers/signin.js.map +1 -1
  24. package/dist-server/index.d.ts +1 -0
  25. package/dist-server/index.js +2 -0
  26. package/dist-server/index.js.map +1 -1
  27. package/dist-server/middlewares/jwt-authenticate-middleware.js +8 -25
  28. package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
  29. package/dist-server/router/auth-public-process-router.js +3 -41
  30. package/dist-server/router/auth-public-process-router.js.map +1 -1
  31. package/dist-server/service/access-request/access-request.js +2 -2
  32. package/dist-server/service/access-request/access-request.js.map +1 -1
  33. package/dist-server/service/audit/audit-kinds.js +1 -0
  34. package/dist-server/service/audit/audit-kinds.js.map +1 -1
  35. package/dist-server/service/audit/audit-subscriber.d.ts +1 -7
  36. package/dist-server/service/audit/audit-subscriber.js +2 -33
  37. package/dist-server/service/audit/audit-subscriber.js.map +1 -1
  38. package/dist-server/service/audit/index.d.ts +1 -1
  39. package/dist-server/service/index.d.ts +2 -4
  40. package/dist-server/service/index.js +22 -27
  41. package/dist-server/service/index.js.map +1 -1
  42. package/dist-server/service/open-fields.js +0 -2
  43. package/dist-server/service/open-fields.js.map +1 -1
  44. package/dist-server/service/user/user-mutation.d.ts +27 -6
  45. package/dist-server/service/user/user-mutation.js +32 -114
  46. package/dist-server/service/user/user-mutation.js.map +1 -1
  47. package/dist-server/service/user/user-query.d.ts +3 -2
  48. package/dist-server/service/user/user-query.js +17 -21
  49. package/dist-server/service/user/user-query.js.map +1 -1
  50. package/dist-server/service/user/user-types.d.ts +6 -2
  51. package/dist-server/service/user/user-types.js +20 -9
  52. package/dist-server/service/user/user-types.js.map +1 -1
  53. package/dist-server/service/user/user.js +17 -8
  54. package/dist-server/service/user/user.js.map +1 -1
  55. package/dist-server/service/verification-token/verification-token.d.ts +1 -0
  56. package/dist-server/service/verification-token/verification-token.js +1 -0
  57. package/dist-server/service/verification-token/verification-token.js.map +1 -1
  58. package/dist-server/templates/reset-password-email.d.ts +10 -3
  59. package/dist-server/templates/reset-password-email.js +8 -4
  60. package/dist-server/templates/reset-password-email.js.map +1 -1
  61. package/dist-server/tsconfig.tsbuildinfo +1 -1
  62. package/dist-server/utils/password-reset-rule.d.ts +15 -0
  63. package/dist-server/utils/password-reset-rule.js +34 -0
  64. package/dist-server/utils/password-reset-rule.js.map +1 -0
  65. package/dist-server/utils/verification-token-rule.js +3 -1
  66. package/dist-server/utils/verification-token-rule.js.map +1 -1
  67. package/package.json +2 -2
  68. package/tests/add-user-gates.test.ts +34 -0
  69. package/tests/list-inheritance-db.test.ts +0 -15
  70. package/tests/lock-recovery.test.ts +2 -2
  71. package/tests/open-doors.test.ts +2 -5
  72. package/tests/password-reset-rule-db.test.ts +159 -0
  73. package/tests/password-setup-db.test.ts +213 -0
  74. package/tests/picture-reducer.test.ts +25 -0
  75. package/tests/verification-token-db.test.ts +12 -7
  76. package/translations/en.json +4 -2
  77. package/translations/es.json +4 -2
  78. package/translations/ja.json +4 -2
  79. package/translations/ko.json +4 -2
  80. package/translations/ms.json +4 -2
  81. package/translations/zh.json +4 -2
  82. package/dist-server/controllers/invitation.d.ts +0 -75
  83. package/dist-server/controllers/invitation.js +0 -195
  84. package/dist-server/controllers/invitation.js.map +0 -1
  85. package/dist-server/controllers/utils/make-invitation-token.d.ts +0 -1
  86. package/dist-server/controllers/utils/make-invitation-token.js +0 -9
  87. package/dist-server/controllers/utils/make-invitation-token.js.map +0 -1
  88. package/dist-server/controllers/utils/save-invitation-token.d.ts +0 -5
  89. package/dist-server/controllers/utils/save-invitation-token.js +0 -12
  90. package/dist-server/controllers/utils/save-invitation-token.js.map +0 -1
  91. package/dist-server/service/invitation/index.d.ts +0 -5
  92. package/dist-server/service/invitation/index.js +0 -9
  93. package/dist-server/service/invitation/index.js.map +0 -1
  94. package/dist-server/service/invitation/invitation-mutation.d.ts +0 -20
  95. package/dist-server/service/invitation/invitation-mutation.js +0 -109
  96. package/dist-server/service/invitation/invitation-mutation.js.map +0 -1
  97. package/dist-server/service/invitation/invitation-query.d.ts +0 -23
  98. package/dist-server/service/invitation/invitation-query.js +0 -89
  99. package/dist-server/service/invitation/invitation-query.js.map +0 -1
  100. package/dist-server/service/invitation/invitation-types.d.ts +0 -5
  101. package/dist-server/service/invitation/invitation-types.js +0 -21
  102. package/dist-server/service/invitation/invitation-types.js.map +0 -1
  103. package/dist-server/service/invitation/invitation.d.ts +0 -26
  104. package/dist-server/service/invitation/invitation.js +0 -142
  105. package/dist-server/service/invitation/invitation.js.map +0 -1
  106. package/dist-server/templates/invitation-email.d.ts +0 -6
  107. package/dist-server/templates/invitation-email.js +0 -82
  108. package/dist-server/templates/invitation-email.js.map +0 -1
  109. package/dist-server/utils/invitation-state.d.ts +0 -52
  110. package/dist-server/utils/invitation-state.js +0 -76
  111. package/dist-server/utils/invitation-state.js.map +0 -1
  112. package/tests/invitation-db.test.ts +0 -416
  113. package/tests/invitation-route.test.ts +0 -116
  114. package/tests/invitation-state.test.ts +0 -92
@@ -0,0 +1,15 @@
1
+ import { Domain, Refusal } from '@things-factory/shell';
2
+ import { EntityManager } from 'typeorm';
3
+ import { User } from '../service/user/user.js';
4
+ /**
5
+ * Whom a domain may put back on the default password.
6
+ *
7
+ * A person has one password for every domain they work in, and the default password is known to everyone who
8
+ * was ever handed it. Resetting one is therefore the same as taking the account: the first one to sign in with
9
+ * the default is sent to set a new password. A domain may do that only to an account that is wholly its own -
10
+ * one that works in no other domain and owns no domain. The system domain's owner (the super user) and any
11
+ * domain's owner are refused here, whichever domain asks; so are `admin` accounts, as `inactivateUser` refuses them.
12
+ *
13
+ * @returns the refusal, or `null` when `target` may be reset from `domain`. `target` must carry its `domains`.
14
+ */
15
+ export declare function passwordResetRefusal(target: User, domain: Domain, tx?: EntityManager): Promise<Refusal | null>;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.passwordResetRefusal = passwordResetRefusal;
4
+ const shell_1 = require("@things-factory/shell");
5
+ const domain_owner_js_1 = require("../service/domain-owner/domain-owner.js");
6
+ /**
7
+ * Whom a domain may put back on the default password.
8
+ *
9
+ * A person has one password for every domain they work in, and the default password is known to everyone who
10
+ * was ever handed it. Resetting one is therefore the same as taking the account: the first one to sign in with
11
+ * the default is sent to set a new password. A domain may do that only to an account that is wholly its own -
12
+ * one that works in no other domain and owns no domain. The system domain's owner (the super user) and any
13
+ * domain's owner are refused here, whichever domain asks; so are `admin` accounts, as `inactivateUser` refuses them.
14
+ *
15
+ * @returns the refusal, or `null` when `target` may be reset from `domain`. `target` must carry its `domains`.
16
+ */
17
+ async function passwordResetRefusal(target, domain, tx) {
18
+ if (target.userType == 'admin' || (await ownsAnyDomain(target, tx))) {
19
+ return new shell_1.Refusal('password-reset-owner-account', {}, "A domain owner's or administrator's password is not reset to the default.");
20
+ }
21
+ const elsewhere = (target.domains || []).filter((userDomain) => userDomain.id !== domain.id);
22
+ if (elsewhere.length > 0) {
23
+ return new shell_1.Refusal('password-reset-reaches-other-domains', { domains: elsewhere.map((userDomain) => userDomain.name).join(', ') }, `The account also works in ${elsewhere.map((userDomain) => userDomain.name).join(', ')} - its password is not this domain's to reset.`);
24
+ }
25
+ return null;
26
+ }
27
+ /** Owner of any domain - through the `DomainOwner` table or the primary owner column (`domain-ownership.ts`). */
28
+ async function ownsAnyDomain(user, tx) {
29
+ if (await (0, shell_1.getRepository)(shell_1.Domain, tx).exists({ where: { owner: user.id } })) {
30
+ return true;
31
+ }
32
+ return await (0, shell_1.getRepository)(domain_owner_js_1.DomainOwner, tx).exists({ where: { user: { id: user.id } } });
33
+ }
34
+ //# sourceMappingURL=password-reset-rule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"password-reset-rule.js","sourceRoot":"","sources":["../../server/utils/password-reset-rule.ts"],"names":[],"mappings":";;AAiBA,oDAuBC;AAxCD,iDAAsE;AAGtE,6EAAqE;AAGrE;;;;;;;;;;GAUG;AACI,KAAK,UAAU,oBAAoB,CACxC,MAAY,EACZ,MAAc,EACd,EAAkB;IAElB,IAAI,MAAM,CAAC,QAAQ,IAAI,OAAO,IAAI,CAAC,MAAM,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;QACpE,OAAO,IAAI,eAAO,CAChB,8BAA8B,EAC9B,EAAE,EACF,2EAA2E,CAC5E,CAAA;IACH,CAAC;IAED,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,UAAkB,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC,CAAA;IACpG,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACzB,OAAO,IAAI,eAAO,CAChB,sCAAsC,EACtC,EAAE,OAAO,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,UAAkB,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAC9E,6BAA6B,SAAS,CAAC,GAAG,CAAC,CAAC,UAAkB,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,gDAAgD,CAC/I,CAAA;IACH,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC;AAED,iHAAiH;AACjH,KAAK,UAAU,aAAa,CAAC,IAAU,EAAE,EAAkB;IACzD,IAAI,MAAM,IAAA,qBAAa,EAAC,cAAM,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC;QAC1E,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,MAAM,IAAA,qBAAa,EAAC,6BAAW,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;AAC1F,CAAC","sourcesContent":["import { Domain, getRepository, Refusal } from '@things-factory/shell'\nimport { EntityManager } from 'typeorm'\n\nimport { DomainOwner } from '../service/domain-owner/domain-owner.js'\nimport { User } from '../service/user/user.js'\n\n/**\n * Whom a domain may put back on the default password.\n *\n * A person has one password for every domain they work in, and the default password is known to everyone who\n * was ever handed it. Resetting one is therefore the same as taking the account: the first one to sign in with\n * the default is sent to set a new password. A domain may do that only to an account that is wholly its own -\n * one that works in no other domain and owns no domain. The system domain's owner (the super user) and any\n * domain's owner are refused here, whichever domain asks; so are `admin` accounts, as `inactivateUser` refuses them.\n *\n * @returns the refusal, or `null` when `target` may be reset from `domain`. `target` must carry its `domains`.\n */\nexport async function passwordResetRefusal(\n target: User,\n domain: Domain,\n tx?: EntityManager\n): Promise<Refusal | null> {\n if (target.userType == 'admin' || (await ownsAnyDomain(target, tx))) {\n return new Refusal(\n 'password-reset-owner-account',\n {},\n \"A domain owner's or administrator's password is not reset to the default.\"\n )\n }\n\n const elsewhere = (target.domains || []).filter((userDomain: Domain) => userDomain.id !== domain.id)\n if (elsewhere.length > 0) {\n return new Refusal(\n 'password-reset-reaches-other-domains',\n { domains: elsewhere.map((userDomain: Domain) => userDomain.name).join(', ') },\n `The account also works in ${elsewhere.map((userDomain: Domain) => userDomain.name).join(', ')} - its password is not this domain's to reset.`\n )\n }\n\n return null\n}\n\n/** Owner of any domain - through the `DomainOwner` table or the primary owner column (`domain-ownership.ts`). */\nasync function ownsAnyDomain(user: User, tx?: EntityManager): Promise<boolean> {\n if (await getRepository(Domain, tx).exists({ where: { owner: user.id } })) {\n return true\n }\n\n return await getRepository(DomainOwner, tx).exists({ where: { user: { id: user.id } } })\n}\n"]}
@@ -27,7 +27,7 @@ const crypto_1 = require("crypto");
27
27
  * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,
28
28
  * with the token.
29
29
  *
30
- * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows
30
+ * A row without `expiresAt` reads as **expired**. Such rows
31
31
  * were written before this existed, with the token in plain text; refusing them is also how links
32
32
  * already sent stop working when this ships.
33
33
  *
@@ -39,6 +39,8 @@ const crypto_1 = require("crypto");
39
39
  const HOUR = 60 * 60 * 1000;
40
40
  exports.VERIFICATION_TOKEN_LIFETIME_MS = Object.freeze({
41
41
  'password-reset': 1 * HOUR,
42
+ /* Handed over by mail or by hand, so it lives as long as an invitation did (ADR-0161 decision 3). */
43
+ 'password-setup': 48 * HOUR,
42
44
  unlock: 1 * HOUR,
43
45
  activation: 48 * HOUR
44
46
  });
@@ -1 +1 @@
1
- {"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA2CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AA/ED,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
1
+ {"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA6CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AAjFD,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,qGAAqG;IACrG,gBAAgB,EAAE,EAAE,GAAG,IAAI;IAC3B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired**. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n /* Handed over by mail or by hand, so it lives as long as an invitation did (ADR-0161 decision 3). */\n 'password-setup': 48 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.213",
3
+ "version": "10.1.218",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -50,5 +50,5 @@
50
50
  "passport-jwt": "^4.0.0",
51
51
  "passport-local": "^1.0.0"
52
52
  },
53
- "gitHead": "81f23aa18cb584c7d8185e81cca0d688f509cf8c"
53
+ "gitHead": "b4e60fdfe307cecebb2264267f616749a502d5fe"
54
54
  }
@@ -0,0 +1,34 @@
1
+ import { buildSchema } from 'type-graphql'
2
+
3
+ import { loadCompiled } from './compiled'
4
+
5
+ /**
6
+ * The questions the user popups ask are gated as the actions they lead to.
7
+ *
8
+ * `checkUserExistence` is asked only by the add-user popup, before `inviteUser` / `createUser`
9
+ * (each `user:mutation`, owner granted). It asked `user:query` alone, so a domain owner without that role was refused
10
+ * the lookup and the popup took the refusal for "no account" - and set out to create an account for someone who
11
+ * had one. `mayIssuePasswordSetupLink` answers for the setup link button, so it stands where `issuePasswordSetupLink` does.
12
+ */
13
+ const { schema: declared } = loadCompiled('service/index')
14
+ const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
15
+
16
+ function gateOf(field: string) {
17
+ const gate = privilegeOfField('Query', field)
18
+ return [gate.known, gate['gate']?.category, gate['gate']?.privilege, gate['gate']?.owner]
19
+ }
20
+
21
+ describe('what the user popups ask', () => {
22
+ beforeAll(async () => {
23
+ const schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
24
+ privilegeDirectiveResolver(schema, { modules: [] })
25
+ })
26
+
27
+ it('★ checkUserExistence asks what adding a user asks - user:mutation, or the owner', () => {
28
+ expect(gateOf('checkUserExistence')).toEqual([true, 'user', 'mutation', true])
29
+ })
30
+
31
+ it('mayIssuePasswordSetupLink asks what issuing asks', () => {
32
+ expect(gateOf('mayIssuePasswordSetupLink')).toEqual([true, 'user', 'mutation', true])
33
+ })
34
+ })
@@ -19,9 +19,6 @@ const { Appliance } = loadCompiled('service/appliance/appliance')
19
19
  const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
20
20
  const { Application } = loadCompiled('service/application/application')
21
21
  const { ApplicationQuery } = loadCompiled('service/application/application-query')
22
- const { Invitation } = loadCompiled('service/invitation/invitation')
23
- const { InvitationStatus } = loadCompiled('utils/invitation-state')
24
- const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
25
22
  const { Partner } = loadCompiled('service/partner/partner')
26
23
  const { PartnerQuery } = loadCompiled('service/partner/partner-query')
27
24
 
@@ -101,18 +98,6 @@ const LISTS: [string, () => Promise<any>, (params: any, context: any) => Promise
101
98
  }),
102
99
  (params, context) => new ApplicationQuery().applications(params, context)
103
100
  ],
104
- [
105
- 'invitations',
106
- () =>
107
- getRepository(Invitation).save({
108
- email: 'lee@acme.z',
109
- domain: parent,
110
- status: InvitationStatus.PENDING,
111
- token: 'parent-token',
112
- expiresAt: new Date(Date.now() + 3600_000)
113
- }),
114
- (params, context) => new InvitationQuery().invitations(params, context)
115
- ],
116
101
  [
117
102
  'partners',
118
103
  () => getRepository(Partner).save({ domain: parent, partnerDomain, requestedAt: new Date() }),
@@ -70,7 +70,7 @@ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
70
70
  const user = await lockedUser()
71
71
  await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
72
72
 
73
- await resetPassword('tok', NEW, context)
73
+ await resetPassword('tok', NEW, 'kim@example.com', context)
74
74
 
75
75
  const after = await getRepository(User).findOneBy({ id: user.id })
76
76
  expect(User.verify(after.password, NEW, after.salt)).toBe(true)
@@ -84,7 +84,7 @@ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
84
84
  const user = await lockedUser()
85
85
  await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
86
86
 
87
- await resetPassword('tok', NEW, context)
87
+ await resetPassword('tok', NEW, 'kim@example.com', context)
88
88
 
89
89
  await expect(signin({ username: 'kim', password: NEW })).rejects.toThrow()
90
90
  })
@@ -81,8 +81,7 @@ describe('the doors that were closed', () => {
81
81
  * domainUserRoleRegister makes a domain, its roles and its users. `domainRegister`
82
82
  * beside it makes only a domain and is super-user only
83
83
  * loginHistories everybody's sign-ins - now the audit's, behind `audit:query` (ADR-0141)
84
- * invitation / invitations and neither of them scopes by tenant at all
85
- * sendInvitation / cancelInvitation / inviteUser
84
+ * inviteUser
86
85
  */
87
86
  const CLOSED = {
88
87
  Mutation: [
@@ -92,11 +91,9 @@ describe('the doors that were closed', () => {
92
91
  'deleteAuthProvider',
93
92
  'deleteAuthProviders',
94
93
  'domainUserRoleRegister',
95
- 'sendInvitation',
96
- 'cancelInvitation',
97
94
  'inviteUser'
98
95
  ],
99
- Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth', 'invitation', 'invitations']
96
+ Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth']
100
97
  }
101
98
 
102
99
  it.each(Object.entries(CLOSED).flatMap(([t, fields]) => fields.map(f => [t, f])))('%s.%s', (typeName, field) => {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Whose password a domain may take back - by issuing a password setup link (ADR-0161).
3
+ *
4
+ * The reset asked only that the person belong to the asking domain. A person has one password for every domain, so a
5
+ * user manager of any domain could take the password of someone who also works elsewhere, of another domain's owner,
6
+ * and of the super user (the system domain's owner) the moment that person was a member here. It was the default
7
+ * password then; it is a setup link now, and the same people are refused.
8
+ */
9
+
10
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
11
+ import { loadCompiled } from './compiled'
12
+
13
+ const { User } = loadCompiled('service/user/user')
14
+ const { UserMutation } = loadCompiled('service/user/user-mutation')
15
+ const { UserQuery } = loadCompiled('service/user/user-query')
16
+ const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
17
+
18
+ const OWN_PASSWORD = 'Mine!5678'
19
+
20
+ let here: any
21
+ let other: any
22
+ let system: any
23
+
24
+ async function makeUser(domains: any[], extra: any = {}) {
25
+ const tag = Math.random().toString(36).slice(2, 8)
26
+ const salt = User.generateSalt()
27
+ return await getRepository(User).save({
28
+ username: `u-${tag}`,
29
+ email: `u-${tag}@example.com`,
30
+ name: 'Kim',
31
+ salt,
32
+ password: User.encode(OWN_PASSWORD, salt),
33
+ status: 'activated',
34
+ domains,
35
+ ...extra
36
+ })
37
+ }
38
+
39
+ function context(domain: any) {
40
+ return {
41
+ state: { domain, tx: undefined },
42
+ t: (key: string) => key,
43
+ headers: {},
44
+ request: { protocol: 'https', host: 'acme.example.com' }
45
+ } as any
46
+ }
47
+
48
+ function reset(user: any, domain: any) {
49
+ return new UserMutation().issuePasswordSetupLink(user.username, false, context(domain))
50
+ }
51
+
52
+ function mayReset(user: any, domain: any) {
53
+ return new UserQuery().mayIssuePasswordSetupLink(user.id, context(domain))
54
+ }
55
+
56
+ async function stillHasOwnPassword(user: any) {
57
+ const read = await getRepository(User).findOneBy({ id: user.id })
58
+ return User.verify(read.password, OWN_PASSWORD, read.salt)
59
+ }
60
+
61
+ beforeAll(async () => {
62
+ await openAuthDatabase()
63
+ })
64
+
65
+ afterAll(async () => {
66
+ await closeAuthDatabase()
67
+ })
68
+
69
+ beforeEach(async () => {
70
+ await resetAuthDatabase()
71
+
72
+ const domainRepository = getRepository(Domain)
73
+ here = await domainRepository.save({ name: 'Plant A', subdomain: 'plant-a' })
74
+ other = await domainRepository.save({ name: 'Plant B', subdomain: 'plant-b' })
75
+ system = await domainRepository.save({ name: 'System', subdomain: 'system' })
76
+ })
77
+
78
+ describe('a person wholly of this domain', () => {
79
+ it('is issued a link and waits for a password, and the screen offers it', async () => {
80
+ const user = await makeUser([here])
81
+
82
+ expect(await mayReset(user, here)).toBe(true)
83
+ await expect(reset(user, here)).resolves.toMatchObject({ link: expect.stringContaining('/auth/reset-password#') })
84
+
85
+ const read = await getRepository(User).findOneBy({ id: user.id })
86
+ expect(read.status).toBe('password_reset_required')
87
+ expect(await stillHasOwnPassword(user)).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('refused, and the password stays', () => {
92
+ it('★ someone who also works in another domain', async () => {
93
+ const user = await makeUser([here, other])
94
+
95
+ expect(await mayReset(user, here)).toBe(false)
96
+ await expect(reset(user, here)).rejects.toMatchObject({
97
+ code: 'password-reset-reaches-other-domains',
98
+ params: { domains: 'Plant B' }
99
+ })
100
+ expect(await stillHasOwnPassword(user)).toBe(true)
101
+ })
102
+
103
+ it("★ another domain's owner, through the owner table, though a member only here", async () => {
104
+ const user = await makeUser([here])
105
+ await getRepository(DomainOwner).save({ domain: other, user })
106
+
107
+ expect(await mayReset(user, here)).toBe(false)
108
+ await expect(reset(user, here)).rejects.toMatchObject({ code: 'password-reset-owner-account' })
109
+ expect(await stillHasOwnPassword(user)).toBe(true)
110
+ })
111
+
112
+ it("★ another domain's primary owner (the column only)", async () => {
113
+ const user = await makeUser([here])
114
+ await getRepository(Domain).update({ id: other.id }, { owner: user.id })
115
+
116
+ expect(await mayReset(user, here)).toBe(false)
117
+ await expect(reset(user, here)).rejects.toMatchObject({ code: 'password-reset-owner-account' })
118
+ expect(await stillHasOwnPassword(user)).toBe(true)
119
+ })
120
+
121
+ it('★ the super user - asked from the system domain itself', async () => {
122
+ const superUser = await makeUser([system])
123
+ await getRepository(Domain).update({ id: system.id }, { owner: superUser.id })
124
+
125
+ expect(await mayReset(superUser, system)).toBe(false)
126
+ await expect(reset(superUser, system)).rejects.toMatchObject({ code: 'password-reset-owner-account' })
127
+ expect(await stillHasOwnPassword(superUser)).toBe(true)
128
+ })
129
+
130
+ it('★ the super user - asked from a domain they are also a member of', async () => {
131
+ const superUser = await makeUser([system, here])
132
+ await getRepository(Domain).update({ id: system.id }, { owner: superUser.id })
133
+
134
+ expect(await mayReset(superUser, here)).toBe(false)
135
+ await expect(reset(superUser, here)).rejects.toMatchObject({ code: 'password-reset-owner-account' })
136
+ expect(await stillHasOwnPassword(superUser)).toBe(true)
137
+ })
138
+
139
+ it("this domain's own owner", async () => {
140
+ const owner = await makeUser([here])
141
+ await getRepository(DomainOwner).save({ domain: here, user: owner })
142
+
143
+ expect(await mayReset(owner, here)).toBe(false)
144
+ await expect(reset(owner, here)).rejects.toMatchObject({ code: 'password-reset-owner-account' })
145
+ })
146
+
147
+ it('an admin account, as inactivateUser refuses it', async () => {
148
+ const admin = await makeUser([here], { userType: 'admin' })
149
+
150
+ expect(await mayReset(admin, here)).toBe(false)
151
+ await expect(reset(admin, here)).rejects.toMatchObject({ code: 'password-reset-owner-account' })
152
+ })
153
+
154
+ it('the screen is told no for someone who is not a member here', async () => {
155
+ const stranger = await makeUser([other])
156
+
157
+ expect(await mayReset(stranger, here)).toBe(false)
158
+ })
159
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * An administrator never sets anybody's password (ADR-0161).
3
+ *
4
+ * It used to be the installation's default password - one value everyone handed one knew, with no end, for every
5
+ * account an administrator made or reset. Now such an account waits for its password, and the person sets it through
6
+ * a one-time link that is mailed or handed over: 48 hours, hashed at rest, asking for the address it was for, and the
7
+ * only live one. Issuing it ends the sessions opened before.
8
+ */
9
+
10
+ import { authDataSource, closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
11
+ import { loadCompiled } from './compiled'
12
+
13
+ const jwt = require('jsonwebtoken')
14
+
15
+ const { User } = loadCompiled('service/user/user')
16
+ const { UserMutation } = loadCompiled('service/user/user-mutation')
17
+ const { VerificationToken } = loadCompiled('service/verification-token/verification-token')
18
+ const { signin } = loadCompiled('controllers/signin')
19
+ const { resetPassword } = loadCompiled('controllers/reset-password')
20
+ const { hashVerificationToken } = loadCompiled('utils/verification-token-rule')
21
+
22
+ const OWN_PASSWORD = 'Mine!5678'
23
+ const NEW_PASSWORD = 'N3w!Passw0rd'
24
+ const HOUR = 60 * 60 * 1000
25
+
26
+ let here: any
27
+
28
+ function context(tx?: any) {
29
+ return {
30
+ state: { domain: here, tx },
31
+ t: (key: string) => key,
32
+ headers: {},
33
+ request: { protocol: 'https', host: 'acme.example.com' }
34
+ } as any
35
+ }
36
+
37
+ async function makeActive(email = 'kim@example.com') {
38
+ const salt = User.generateSalt()
39
+ return await getRepository(User).save({
40
+ username: email,
41
+ email,
42
+ name: 'Kim',
43
+ salt,
44
+ password: User.encode(OWN_PASSWORD, salt),
45
+ status: 'activated',
46
+ domains: [here]
47
+ })
48
+ }
49
+
50
+ function create(email = 'lee@example.com') {
51
+ return new UserMutation().createUser({ username: email, email, name: 'Lee', userType: 'user' }, context())
52
+ }
53
+
54
+ function issue(user: any, mail = false, tx?: any) {
55
+ return new UserMutation().issuePasswordSetupLink(user.username, mail, context(tx))
56
+ }
57
+
58
+ const tokenOf = (link: string) => link.split('#')[1]
59
+ const reload = (id: string) => getRepository(User).findOneBy({ id })
60
+ const setPassword = (token: string, email: string, password = NEW_PASSWORD) =>
61
+ resetPassword(token, password, email, { t: (key: string) => key })
62
+
63
+ beforeAll(async () => {
64
+ await openAuthDatabase()
65
+ })
66
+
67
+ afterAll(async () => {
68
+ await closeAuthDatabase()
69
+ })
70
+
71
+ beforeEach(async () => {
72
+ await resetAuthDatabase()
73
+ here = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
74
+ })
75
+
76
+ describe('an account an administrator makes', () => {
77
+ it('★ waits for its password, which nobody was given', async () => {
78
+ const made = await create()
79
+ const read = await reload(made.id)
80
+
81
+ expect(read.status).toBe('password_reset_required')
82
+ expect(read.password).toBeTruthy()
83
+ })
84
+
85
+ it('★ does not sign in, and a guess against it is not counted', async () => {
86
+ const made = await create()
87
+
88
+ await expect(signin({ username: made.username, password: 'anything' })).rejects.toMatchObject({
89
+ errorCode: 'password-setup-pending'
90
+ })
91
+ expect((await reload(made.id)).failCount ?? 0).toBe(0)
92
+ })
93
+
94
+ it('★ is refused on every request too', async () => {
95
+ const made = await create()
96
+
97
+ await expect(User.checkAuth({ username: made.username, email: made.email })).rejects.toMatchObject({
98
+ errorCode: 'password-setup-pending'
99
+ })
100
+ })
101
+
102
+ it('is not opened by activating it', async () => {
103
+ const made = await create()
104
+
105
+ await expect(new UserMutation().activateUser(made.username, context())).rejects.toMatchObject({
106
+ code: 'password-setup-pending'
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('the setup link', () => {
112
+ it('★ is handed back once, kept only as a hash, and lives 48 hours', async () => {
113
+ const made = await create()
114
+ const before = Date.now()
115
+
116
+ const issued = await issue(made)
117
+
118
+ expect(issued.link).toMatch(/^https?:\/\/acme\.example\.com\/auth\/reset-password#[0-9a-f]{32}$/)
119
+ const stored = await getRepository(VerificationToken).findOneBy({ userId: made.id })
120
+ expect(stored.type).toBe('password-setup')
121
+ expect(stored.token).toBe(hashVerificationToken(tokenOf(issued.link)))
122
+ expect(stored.token).not.toBe(tokenOf(issued.link))
123
+ expect(new Date(stored.expiresAt).getTime() - before).toBeGreaterThanOrEqual(48 * HOUR - 1000)
124
+ expect(new Date(stored.expiresAt).getTime() - before).toBeLessThanOrEqual(48 * HOUR + 5000)
125
+ })
126
+
127
+ it('★ opens the account with the address it was for, and then signs in', async () => {
128
+ const made = await create()
129
+ const { link } = await issue(made)
130
+
131
+ await setPassword(tokenOf(link), 'LEE@example.com ')
132
+
133
+ expect((await reload(made.id)).status).toBe('activated')
134
+ await expect(signin({ username: made.username, password: NEW_PASSWORD })).resolves.toMatchObject({
135
+ token: expect.any(String)
136
+ })
137
+ })
138
+
139
+ it('★ is not enough without the address it was for', async () => {
140
+ const made = await create()
141
+ const { link } = await issue(made)
142
+
143
+ await expect(setPassword(tokenOf(link), 'someone@else.example')).rejects.toThrow()
144
+ await expect(setPassword(tokenOf(link), '')).rejects.toThrow()
145
+ expect((await reload(made.id)).status).toBe('password_reset_required')
146
+ })
147
+
148
+ it('works once', async () => {
149
+ const made = await create()
150
+ const { link } = await issue(made)
151
+
152
+ await setPassword(tokenOf(link), made.email)
153
+ await expect(setPassword(tokenOf(link), made.email, 'An0ther!Pass')).rejects.toThrow()
154
+ })
155
+
156
+ it('★ issued again, retires the last one', async () => {
157
+ const made = await create()
158
+ const first = await issue(made)
159
+ const second = await issue(made)
160
+
161
+ await expect(setPassword(tokenOf(first.link), made.email)).rejects.toThrow()
162
+ await setPassword(tokenOf(second.link), made.email)
163
+ expect((await reload(made.id)).status).toBe('activated')
164
+ })
165
+ })
166
+
167
+ describe('taking back the password of an account in use', () => {
168
+ it('★ stops the old password and ends the sessions opened before', async () => {
169
+ const kim = await makeActive()
170
+ const { token: before } = await signin({ username: kim.username, password: OWN_PASSWORD })
171
+ await expect(User.checkAuth(jwt.decode(before))).resolves.toBeTruthy()
172
+
173
+ const { link } = await issue(kim)
174
+
175
+ await expect(signin({ username: kim.username, password: OWN_PASSWORD })).rejects.toMatchObject({
176
+ errorCode: 'password-setup-pending'
177
+ })
178
+
179
+ await setPassword(tokenOf(link), kim.email)
180
+
181
+ /* Open again - but the session from before stays ended: its generation has passed. */
182
+ await expect(User.checkAuth(jwt.decode(before))).rejects.toThrow()
183
+ const { token: after } = await signin({ username: kim.username, password: NEW_PASSWORD })
184
+ await expect(User.checkAuth(jwt.decode(after))).resolves.toBeTruthy()
185
+ })
186
+
187
+ it('★ by mail - a mail that does not go out changes nothing', async () => {
188
+ const kim = await makeActive()
189
+
190
+ await expect(
191
+ authDataSource().transaction(async (tx: any) => await issue(kim, true, tx))
192
+ ).rejects.toMatchObject({ code: 'password-setup-mail-not-sent' })
193
+
194
+ const read = await reload(kim.id)
195
+ expect(read.status).toBe('activated')
196
+ expect(User.verify(read.password, OWN_PASSWORD, read.salt)).toBe(true)
197
+ expect(await getRepository(VerificationToken).findOneBy({ userId: kim.id })).toBeNull()
198
+ })
199
+ })
200
+
201
+ describe('a forgotten password', () => {
202
+ it('asks for the address as well, through the same page', async () => {
203
+ const kim = await makeActive()
204
+ const { makeVerificationToken } = loadCompiled('controllers/utils/make-verification-token')
205
+ const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
206
+ const token = makeVerificationToken()
207
+ await saveVerificationToken(kim.id, token, 'password-reset')
208
+
209
+ await expect(setPassword(token, 'someone@else.example')).rejects.toThrow()
210
+ await setPassword(token, kim.email)
211
+ await expect(signin({ username: kim.username, password: NEW_PASSWORD })).resolves.toBeTruthy()
212
+ })
213
+ })
@@ -0,0 +1,25 @@
1
+ import { SET_PICTURE, SET_PROFILE } from '../client/actions/auth'
2
+ import auth from '../client/reducers/auth'
3
+
4
+ /** The signed-in person's picture, which the header's avatar draws (`auth.picture`). */
5
+ describe('the signed-in person\'s picture', () => {
6
+ const picture = { picture: '/attachment/face.png', left: 0, top: 0, zoom: 1 }
7
+
8
+ it('starts with none - the avatar draws a monogram', () => {
9
+ expect(auth(undefined, { type: '@@INIT' }).picture).toBeNull()
10
+ })
11
+
12
+ it('★ is set by SET_PICTURE and survives a later profile', () => {
13
+ let state = auth(undefined, { type: SET_PICTURE, picture })
14
+ state = auth(state, { type: SET_PROFILE, user: { name: '남상혁' } })
15
+
16
+ expect(state.picture).toEqual(picture)
17
+ expect(state.user).toEqual({ name: '남상혁' })
18
+ })
19
+
20
+ it('a missing picture clears it', () => {
21
+ const state = auth(auth(undefined, { type: SET_PICTURE, picture }), { type: SET_PICTURE, picture: undefined })
22
+
23
+ expect(state.picture).toBeNull()
24
+ })
25
+ })