@endora-commerce/mod-admin-users 0.100.0

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/dist/admin/index.d.ts +47 -0
  4. package/dist/admin/index.d.ts.map +1 -0
  5. package/dist/admin/index.js +44 -0
  6. package/dist/admin/index.js.map +1 -0
  7. package/dist/admin/pages/AdminRolesPage.d.ts +3 -0
  8. package/dist/admin/pages/AdminRolesPage.d.ts.map +1 -0
  9. package/dist/admin/pages/AdminRolesPage.js +180 -0
  10. package/dist/admin/pages/AdminRolesPage.js.map +1 -0
  11. package/dist/admin/pages/AdminUsersPage.d.ts +3 -0
  12. package/dist/admin/pages/AdminUsersPage.d.ts.map +1 -0
  13. package/dist/admin/pages/AdminUsersPage.js +151 -0
  14. package/dist/admin/pages/AdminUsersPage.js.map +1 -0
  15. package/dist/admin/permission-label.d.ts +26 -0
  16. package/dist/admin/permission-label.d.ts.map +1 -0
  17. package/dist/admin/permission-label.js +67 -0
  18. package/dist/admin/permission-label.js.map +1 -0
  19. package/dist/backend/cli/create-admin.d.ts +16 -0
  20. package/dist/backend/cli/create-admin.d.ts.map +1 -0
  21. package/dist/backend/cli/create-admin.js +101 -0
  22. package/dist/backend/cli/create-admin.js.map +1 -0
  23. package/dist/backend/demo/reset.d.ts +15 -0
  24. package/dist/backend/demo/reset.d.ts.map +1 -0
  25. package/dist/backend/demo/reset.js +13 -0
  26. package/dist/backend/demo/reset.js.map +1 -0
  27. package/dist/backend/demo/rows.d.ts +50 -0
  28. package/dist/backend/demo/rows.d.ts.map +1 -0
  29. package/dist/backend/demo/rows.js +73 -0
  30. package/dist/backend/demo/rows.js.map +1 -0
  31. package/dist/backend/demo/seed.d.ts +24 -0
  32. package/dist/backend/demo/seed.d.ts.map +1 -0
  33. package/dist/backend/demo/seed.js +43 -0
  34. package/dist/backend/demo/seed.js.map +1 -0
  35. package/dist/backend/entities/admin-user.entity.d.ts +44 -0
  36. package/dist/backend/entities/admin-user.entity.d.ts.map +1 -0
  37. package/dist/backend/entities/admin-user.entity.js +115 -0
  38. package/dist/backend/entities/admin-user.entity.js.map +1 -0
  39. package/dist/backend/index.d.ts +77 -0
  40. package/dist/backend/index.d.ts.map +1 -0
  41. package/dist/backend/index.js +111 -0
  42. package/dist/backend/index.js.map +1 -0
  43. package/dist/backend/plugin.d.ts +70 -0
  44. package/dist/backend/plugin.d.ts.map +1 -0
  45. package/dist/backend/plugin.js +50 -0
  46. package/dist/backend/plugin.js.map +1 -0
  47. package/dist/backend/routes.admin.d.ts +32 -0
  48. package/dist/backend/routes.admin.d.ts.map +1 -0
  49. package/dist/backend/routes.admin.js +192 -0
  50. package/dist/backend/routes.admin.js.map +1 -0
  51. package/dist/backend/routes.impersonation.d.ts +25 -0
  52. package/dist/backend/routes.impersonation.d.ts.map +1 -0
  53. package/dist/backend/routes.impersonation.js +108 -0
  54. package/dist/backend/routes.impersonation.js.map +1 -0
  55. package/dist/backend/routes.public.d.ts +20 -0
  56. package/dist/backend/routes.public.d.ts.map +1 -0
  57. package/dist/backend/routes.public.js +75 -0
  58. package/dist/backend/routes.public.js.map +1 -0
  59. package/dist/backend/services/admin-auth-service.d.ts +47 -0
  60. package/dist/backend/services/admin-auth-service.d.ts.map +1 -0
  61. package/dist/backend/services/admin-auth-service.js +93 -0
  62. package/dist/backend/services/admin-auth-service.js.map +1 -0
  63. package/dist/backend/services/admin-user-ports.d.ts +75 -0
  64. package/dist/backend/services/admin-user-ports.d.ts.map +1 -0
  65. package/dist/backend/services/admin-user-ports.js +160 -0
  66. package/dist/backend/services/admin-user-ports.js.map +1 -0
  67. package/dist/backend/services/admin-user-service.d.ts +134 -0
  68. package/dist/backend/services/admin-user-service.d.ts.map +1 -0
  69. package/dist/backend/services/admin-user-service.js +241 -0
  70. package/dist/backend/services/admin-user-service.js.map +1 -0
  71. package/dist/backend/services/impersonation-service.d.ts +65 -0
  72. package/dist/backend/services/impersonation-service.d.ts.map +1 -0
  73. package/dist/backend/services/impersonation-service.js +103 -0
  74. package/dist/backend/services/impersonation-service.js.map +1 -0
  75. package/dist/backend/services/two-factor-enrolments.d.ts +19 -0
  76. package/dist/backend/services/two-factor-enrolments.d.ts.map +1 -0
  77. package/dist/backend/services/two-factor-enrolments.js +2 -0
  78. package/dist/backend/services/two-factor-enrolments.js.map +1 -0
  79. package/dist/manifest.d.ts +182 -0
  80. package/dist/manifest.d.ts.map +1 -0
  81. package/dist/manifest.js +208 -0
  82. package/dist/manifest.js.map +1 -0
  83. package/dist/migrations/20260425T053028_admin_users_init.d.ts +11 -0
  84. package/dist/migrations/20260425T053028_admin_users_init.d.ts.map +1 -0
  85. package/dist/migrations/20260425T053028_admin_users_init.js +51 -0
  86. package/dist/migrations/20260425T053028_admin_users_init.js.map +1 -0
  87. package/dist/migrations/20260819T155150_admin_users_fold_email_case.d.ts +55 -0
  88. package/dist/migrations/20260819T155150_admin_users_fold_email_case.d.ts.map +1 -0
  89. package/dist/migrations/20260819T155150_admin_users_fold_email_case.js +106 -0
  90. package/dist/migrations/20260819T155150_admin_users_fold_email_case.js.map +1 -0
  91. package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.d.ts +25 -0
  92. package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.d.ts.map +1 -0
  93. package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.js +29 -0
  94. package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.js.map +1 -0
  95. package/dist/migrations/index.d.ts +29 -0
  96. package/dist/migrations/index.d.ts.map +1 -0
  97. package/dist/migrations/index.js +33 -0
  98. package/dist/migrations/index.js.map +1 -0
  99. package/docs/admin_users.md +53 -0
  100. package/i18n/en.json +5 -0
  101. package/i18n/pl.json +5 -0
  102. package/package.json +100 -0
  103. package/tailwind.css +14 -0
@@ -0,0 +1,134 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import { type AdminRolePort, type AuthSessionPort } from '@endora-commerce/contracts';
3
+ import { AdminUser } from '../entities/admin-user.entity.js';
4
+ import type { AuditPort } from '@endora-commerce/platform/kernel';
5
+ /**
6
+ * AdminUserService (T193 / FR-080..FR-083). Backs the admin panel's
7
+ * Users & Roles module. Mutations are gated by `admin_users:manage` at the
8
+ * route layer; this service trusts the caller and focuses on data integrity:
9
+ *
10
+ * - email uniqueness (DB partial unique enforces, mapped to 409)
11
+ * - role existence on assignment
12
+ * - password rehash on create, on self-rotation, and on a peer reset
13
+ * - soft delete via `deletedAt`; status flip is the everyday lever
14
+ */
15
+ export interface CreateAdminUserInput {
16
+ email: string;
17
+ password: string;
18
+ firstName: string;
19
+ lastName: string;
20
+ adminRoleId?: string | null;
21
+ }
22
+ export interface UpdateAdminUserInput {
23
+ firstName?: string;
24
+ lastName?: string;
25
+ adminRoleId?: string | null;
26
+ status?: 'active' | 'inactive';
27
+ /** Optional password rotation. When supplied the value is hashed
28
+ * before being persisted. */
29
+ password?: string;
30
+ }
31
+ export interface ListAdminUsersOptions {
32
+ /** Case-insensitive substring on first name, last name, and email. Trimmed. */
33
+ q?: string;
34
+ /** Zero-indexed page (matches the catalog admin convention). */
35
+ page?: number;
36
+ /** Page size. Default 50, max 200. */
37
+ pageSize?: number;
38
+ }
39
+ export interface ListAdminUsersResult {
40
+ items: AdminUser[];
41
+ page: number;
42
+ pageSize: number;
43
+ total: number;
44
+ }
45
+ export declare class AdminUserService {
46
+ #private;
47
+ private readonly emFactory;
48
+ /**
49
+ * `admin_roles`' published role surface (feature 075, Phase C). The role
50
+ * a user is assigned to belongs to that module, so "does this role exist?"
51
+ * is its question and not a second `em.findOne` against its table.
52
+ */
53
+ private readonly adminRoles;
54
+ /**
55
+ * `auth`'s session surface (feature 075, Phase C). A password write has to
56
+ * be able to withdraw the sessions the old password minted, and those rows
57
+ * belong to `auth`.
58
+ */
59
+ private readonly sessions;
60
+ private readonly auditLog?;
61
+ constructor(emFactory: () => EntityManager,
62
+ /**
63
+ * `admin_roles`' published role surface (feature 075, Phase C). The role
64
+ * a user is assigned to belongs to that module, so "does this role exist?"
65
+ * is its question and not a second `em.findOne` against its table.
66
+ */
67
+ adminRoles: AdminRolePort,
68
+ /**
69
+ * `auth`'s session surface (feature 075, Phase C). A password write has to
70
+ * be able to withdraw the sessions the old password minted, and those rows
71
+ * belong to `auth`.
72
+ */
73
+ sessions: AuthSessionPort, auditLog?: AuditPort | undefined);
74
+ /**
75
+ * List non-deleted admin users with optional substring search and offset
76
+ * pagination. When `q` is provided, results are ordered by a relevance
77
+ * CASE: first-name prefix > first-name contains > last-name prefix >
78
+ * last-name contains > email prefix > email contains, then by email.
79
+ * When `q` is empty, results are ordered by email ASC (legacy default).
80
+ *
81
+ * Note: search is case-insensitive but NOT diacritic-insensitive on the
82
+ * server side — Postgres `unaccent` is not enabled in this codebase. The
83
+ * admin Combobox handles diacritic folding client-side when `manualFilter`
84
+ * is off; the server falls back to plain `LOWER(...) LIKE`. If a future
85
+ * picker (customers, products) needs diacritic-insensitive server search,
86
+ * add `unaccent` via a migration.
87
+ */
88
+ list(options?: ListAdminUsersOptions): Promise<ListAdminUsersResult>;
89
+ getById(id: string): Promise<AdminUser>;
90
+ /**
91
+ * Batch lookup for display enrichment (e.g. the audit-log actor column):
92
+ * returns the found admin users for the given ids, tolerating unknown or
93
+ * soft-deleted ids (they are simply omitted). Order is not guaranteed.
94
+ */
95
+ listByIds(ids: string[]): Promise<AdminUser[]>;
96
+ create(input: CreateAdminUserInput): Promise<AdminUser>;
97
+ update(id: string, input: UpdateAdminUserInput): Promise<AdminUser>;
98
+ /**
99
+ * Set an admin user's password on someone else's authority — issue #252.
100
+ *
101
+ * This is the module's password-write seam, and it is written to be the only
102
+ * one: the e-mail-keyed self-service reset that follows the packaging
103
+ * programme differs in **who is authorised** (a token instead of a peer's
104
+ * `admin_users:manage`), not in what the write does. It calls this.
105
+ *
106
+ * Two things happen, in this order:
107
+ *
108
+ * 1. every session the target holds is revoked, and
109
+ * 2. the new hash is persisted with an `admin_user.change_password` audit
110
+ * row that records the target and the route taken — never the password
111
+ * and never its hash.
112
+ *
113
+ * The revocation runs **before** the flush on purpose. It reaches another
114
+ * module, so it can refuse; refusing first means the reset either takes
115
+ * effect whole or not at all, where a flush-then-revoke order could leave a
116
+ * changed password with the old password's sessions still answering. A
117
+ * revocation that succeeded over a flush that then failed only signs the
118
+ * target out, which their existing password undoes.
119
+ *
120
+ * No `catch` around the port call: an operator told "reset" while the old
121
+ * sessions kept working would be told something false.
122
+ */
123
+ resetPassword(id: string, newPassword: string): Promise<AdminUser>;
124
+ softDelete(id: string): Promise<void>;
125
+ /**
126
+ * Set (or revert) an admin user's preferred Admin UI language —
127
+ * feature 019 / FR-002, FR-004, FR-006. Validation against the
128
+ * supported allowlist lives at the route boundary so this method
129
+ * trusts its input. Passing `null` reverts to the platform default
130
+ * ("no preference saved" → resolver treats as English).
131
+ */
132
+ setPreferredLanguage(id: string, preferredLanguage: string | null): Promise<AdminUser>;
133
+ }
134
+ //# sourceMappingURL=admin-user-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-user-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/admin-user-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AAGpC,OAAO,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAE7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE;;;;;;;;;GASG;AAEH,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC/B;kCAC8B;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,+EAA+E;IAC/E,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,gBAAgB;;IAEzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAbT,SAAS,EAAE,MAAM,aAAa;IAC/C;;;;OAIG;IACc,UAAU,EAAE,aAAa;IAC1C;;;;OAIG;IACc,QAAQ,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,SAAS,YAAA;IASvC;;;;;;;;;;;;;OAaG;IACG,IAAI,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,oBAAoB,CAAC;IA6DxE,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAO7C;;;;OAIG;IACG,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAO9C,MAAM,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,SAAS,CAAC;IA4BvD,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,SAAS,CAAC;IAkBzE;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACG,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAgBlE,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS3C;;;;;;OAMG;IACG,oBAAoB,CACxB,EAAE,EAAE,MAAM,EACV,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAC/B,OAAO,CAAC,SAAS,CAAC;CAwBtB"}
@@ -0,0 +1,241 @@
1
+ import { UniqueConstraintViolationException } from '@mikro-orm/core';
2
+ import { ERROR_CODES, normalizeEmailAddress, } from '@endora-commerce/contracts';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { hashPassword } from '@endora-commerce/platform/kernel';
5
+ import { AdminUser } from '../entities/admin-user.entity.js';
6
+ import { recordAuditFromContext } from '@endora-commerce/platform/commands';
7
+ export class AdminUserService {
8
+ emFactory;
9
+ adminRoles;
10
+ sessions;
11
+ auditLog;
12
+ constructor(emFactory,
13
+ /**
14
+ * `admin_roles`' published role surface (feature 075, Phase C). The role
15
+ * a user is assigned to belongs to that module, so "does this role exist?"
16
+ * is its question and not a second `em.findOne` against its table.
17
+ */
18
+ adminRoles,
19
+ /**
20
+ * `auth`'s session surface (feature 075, Phase C). A password write has to
21
+ * be able to withdraw the sessions the old password minted, and those rows
22
+ * belong to `auth`.
23
+ */
24
+ sessions, auditLog) {
25
+ this.emFactory = emFactory;
26
+ this.adminRoles = adminRoles;
27
+ this.sessions = sessions;
28
+ this.auditLog = auditLog;
29
+ }
30
+ #audit(em, action, objectId, stateBefore, stateAfter) {
31
+ if (this.auditLog) {
32
+ recordAuditFromContext(this.auditLog, em, { action, objectType: 'admin_user', objectId, stateBefore, stateAfter });
33
+ }
34
+ }
35
+ /**
36
+ * List non-deleted admin users with optional substring search and offset
37
+ * pagination. When `q` is provided, results are ordered by a relevance
38
+ * CASE: first-name prefix > first-name contains > last-name prefix >
39
+ * last-name contains > email prefix > email contains, then by email.
40
+ * When `q` is empty, results are ordered by email ASC (legacy default).
41
+ *
42
+ * Note: search is case-insensitive but NOT diacritic-insensitive on the
43
+ * server side — Postgres `unaccent` is not enabled in this codebase. The
44
+ * admin Combobox handles diacritic folding client-side when `manualFilter`
45
+ * is off; the server falls back to plain `LOWER(...) LIKE`. If a future
46
+ * picker (customers, products) needs diacritic-insensitive server search,
47
+ * add `unaccent` via a migration.
48
+ */
49
+ async list(options = {}) {
50
+ const em = this.emFactory();
51
+ const page = Math.max(0, options.page ?? 0);
52
+ const pageSize = Math.min(Math.max(1, options.pageSize ?? 50), 200);
53
+ const trimmedQ = options.q?.trim() ?? '';
54
+ if (trimmedQ === '') {
55
+ const [items, total] = await em.findAndCount(AdminUser, { deletedAt: null }, { orderBy: { email: 'asc' }, offset: page * pageSize, limit: pageSize });
56
+ return { items, page, pageSize, total };
57
+ }
58
+ const knex = em.getKnex();
59
+ const needle = trimmedQ.toLowerCase();
60
+ const prefix = `${needle}%`;
61
+ const contains = `%${needle}%`;
62
+ const baseQuery = knex('admin_users')
63
+ .whereNull('deleted_at')
64
+ .andWhere((qb) => {
65
+ qb.whereRaw('LOWER("first_name") LIKE ?', [contains])
66
+ .orWhereRaw('LOWER("last_name") LIKE ?', [contains])
67
+ .orWhereRaw('LOWER("email") LIKE ?', [contains]);
68
+ });
69
+ const totalRow = (await baseQuery
70
+ .clone()
71
+ .count('* as count')
72
+ .first());
73
+ const total = Number(totalRow?.count ?? 0);
74
+ const idRows = (await baseQuery
75
+ .clone()
76
+ .select('id')
77
+ .orderByRaw(`CASE
78
+ WHEN LOWER("first_name") LIKE ? THEN 1
79
+ WHEN LOWER("first_name") LIKE ? THEN 2
80
+ WHEN LOWER("last_name") LIKE ? THEN 3
81
+ WHEN LOWER("last_name") LIKE ? THEN 4
82
+ WHEN LOWER("email") LIKE ? THEN 5
83
+ WHEN LOWER("email") LIKE ? THEN 6
84
+ ELSE 7
85
+ END ASC, "email" ASC`, [prefix, contains, prefix, contains, prefix, contains])
86
+ .offset(page * pageSize)
87
+ .limit(pageSize));
88
+ const ids = idRows.map((r) => r.id);
89
+ if (ids.length === 0)
90
+ return { items: [], page, pageSize, total };
91
+ const found = await em.find(AdminUser, { id: { $in: ids } });
92
+ // Preserve the SQL ordering — `find` with `$in` returns arbitrary order.
93
+ const byId = new Map(found.map((u) => [u.id, u]));
94
+ const items = ids.map((id) => byId.get(id)).filter((u) => Boolean(u));
95
+ return { items, page, pageSize, total };
96
+ }
97
+ async getById(id) {
98
+ const em = this.emFactory();
99
+ const row = await em.findOne(AdminUser, { id, deletedAt: null });
100
+ if (!row)
101
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Admin user not found.');
102
+ return row;
103
+ }
104
+ /**
105
+ * Batch lookup for display enrichment (e.g. the audit-log actor column):
106
+ * returns the found admin users for the given ids, tolerating unknown or
107
+ * soft-deleted ids (they are simply omitted). Order is not guaranteed.
108
+ */
109
+ async listByIds(ids) {
110
+ const unique = [...new Set(ids.filter(Boolean))];
111
+ if (unique.length === 0)
112
+ return [];
113
+ const em = this.emFactory();
114
+ return em.find(AdminUser, { id: { $in: unique } });
115
+ }
116
+ async create(input) {
117
+ const em = this.emFactory();
118
+ if (input.adminRoleId)
119
+ await this.#assertRoleExists(input.adminRoleId);
120
+ const passwordHash = await hashPassword(input.password);
121
+ const user = em.create(AdminUser, {
122
+ email: normalizeEmailAddress(input.email),
123
+ passwordHash,
124
+ firstName: input.firstName,
125
+ lastName: input.lastName,
126
+ ...(input.adminRoleId ? { adminRoleId: input.adminRoleId } : {}),
127
+ status: 'active',
128
+ });
129
+ this.#audit(em, 'admin_user.create', user.id, null, { email: user.email });
130
+ try {
131
+ await em.persistAndFlush(user);
132
+ }
133
+ catch (err) {
134
+ if (err instanceof UniqueConstraintViolationException) {
135
+ throw new HttpError(409, ERROR_CODES.EMAIL_ALREADY_REGISTERED, 'An admin user with that email already exists.');
136
+ }
137
+ throw err;
138
+ }
139
+ return user;
140
+ }
141
+ async update(id, input) {
142
+ const em = this.emFactory();
143
+ const user = await this.#getByIdOn(em, id);
144
+ if (input.adminRoleId !== undefined) {
145
+ if (input.adminRoleId !== null)
146
+ await this.#assertRoleExists(input.adminRoleId);
147
+ user.adminRoleId = input.adminRoleId;
148
+ }
149
+ if (input.firstName !== undefined)
150
+ user.firstName = input.firstName;
151
+ if (input.lastName !== undefined)
152
+ user.lastName = input.lastName;
153
+ if (input.status !== undefined)
154
+ user.status = input.status;
155
+ if (input.password !== undefined) {
156
+ user.passwordHash = await hashPassword(input.password);
157
+ }
158
+ this.#audit(em, 'admin_user.update', user.id, null, { email: user.email, status: user.status });
159
+ await em.flush();
160
+ return user;
161
+ }
162
+ /**
163
+ * Set an admin user's password on someone else's authority — issue #252.
164
+ *
165
+ * This is the module's password-write seam, and it is written to be the only
166
+ * one: the e-mail-keyed self-service reset that follows the packaging
167
+ * programme differs in **who is authorised** (a token instead of a peer's
168
+ * `admin_users:manage`), not in what the write does. It calls this.
169
+ *
170
+ * Two things happen, in this order:
171
+ *
172
+ * 1. every session the target holds is revoked, and
173
+ * 2. the new hash is persisted with an `admin_user.change_password` audit
174
+ * row that records the target and the route taken — never the password
175
+ * and never its hash.
176
+ *
177
+ * The revocation runs **before** the flush on purpose. It reaches another
178
+ * module, so it can refuse; refusing first means the reset either takes
179
+ * effect whole or not at all, where a flush-then-revoke order could leave a
180
+ * changed password with the old password's sessions still answering. A
181
+ * revocation that succeeded over a flush that then failed only signs the
182
+ * target out, which their existing password undoes.
183
+ *
184
+ * No `catch` around the port call: an operator told "reset" while the old
185
+ * sessions kept working would be told something false.
186
+ */
187
+ async resetPassword(id, newPassword) {
188
+ const em = this.emFactory();
189
+ const user = await this.#getByIdOn(em, id);
190
+ // Hashed before the revocation so the window between "signed out" and
191
+ // "new password live" is not an argon2 pass wide.
192
+ const passwordHash = await hashPassword(newPassword);
193
+ await this.sessions.destroyAllForAdmin(user.id);
194
+ user.passwordHash = passwordHash;
195
+ this.#audit(em, 'admin_user.change_password', user.id, null, {
196
+ email: user.email,
197
+ via: 'peer_reset',
198
+ });
199
+ await em.flush();
200
+ return user;
201
+ }
202
+ async softDelete(id) {
203
+ const em = this.emFactory();
204
+ const user = await this.#getByIdOn(em, id);
205
+ user.deletedAt = new Date();
206
+ user.status = 'inactive';
207
+ this.#audit(em, 'admin_user.delete', user.id, { email: user.email }, null);
208
+ await em.flush();
209
+ }
210
+ /**
211
+ * Set (or revert) an admin user's preferred Admin UI language —
212
+ * feature 019 / FR-002, FR-004, FR-006. Validation against the
213
+ * supported allowlist lives at the route boundary so this method
214
+ * trusts its input. Passing `null` reverts to the platform default
215
+ * ("no preference saved" → resolver treats as English).
216
+ */
217
+ async setPreferredLanguage(id, preferredLanguage) {
218
+ // command-coverage-ignore: per-admin Admin-UI language preference — personal
219
+ // UI setting, not an audited domain-state mutation.
220
+ const em = this.emFactory();
221
+ const user = await this.#getByIdOn(em, id);
222
+ user.preferredLanguage = preferredLanguage;
223
+ await em.flush();
224
+ return user;
225
+ }
226
+ async #getByIdOn(em, id) {
227
+ const row = await em.findOne(AdminUser, { id, deletedAt: null });
228
+ if (!row)
229
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Admin user not found.');
230
+ return row;
231
+ }
232
+ /**
233
+ * `getById` raises the same 404 `Admin role not found.` this method used to
234
+ * raise itself, so no `catch` is wanted and none is written: an absent
235
+ * `admin_roles` must refuse the assignment, not let it through unvalidated.
236
+ */
237
+ async #assertRoleExists(id) {
238
+ await this.adminRoles.getById(id);
239
+ }
240
+ }
241
+ //# sourceMappingURL=admin-user-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-user-service.js","sourceRoot":"","sources":["../../../src/backend/services/admin-user-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,65 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import { type AuthSessionPort, type CustomerAccountReadPort, type CustomerAccountRecord } from '@endora-commerce/contracts';
3
+ import type { AuditPort } from '@endora-commerce/platform/kernel';
4
+ /**
5
+ * ImpersonationService (T189). Implements the switch-user pattern:
6
+ * - start(adminSessionId, customerAccountId, reason?) — destroys (no:
7
+ * DOES NOT destroy) the admin session, mints a new "impersonation"
8
+ * session whose customer scope is the target customer AND whose
9
+ * impersonatorAdminUserId is the original admin. The original admin
10
+ * session id is returned as a "shadow" cookie so end can restore it.
11
+ * - end(impersonationSessionId, adminShadowSessionId) — destroys the
12
+ * impersonation session, returns the (still alive) admin session id.
13
+ *
14
+ * AuditPort.record is called BEFORE the cookie is minted (T179
15
+ * "audit-before-cookie") so a partial failure can never leave an
16
+ * impersonation cookie without an audit row.
17
+ */
18
+ export interface ImpersonationStartResult {
19
+ impersonationSessionId: string;
20
+ impersonationCookieValue: string;
21
+ impersonationExpiresAt: Date;
22
+ adminShadowSessionCookieValue: string;
23
+ impersonatedCustomerAccount: CustomerAccountRecord;
24
+ }
25
+ export interface ImpersonationEndResult {
26
+ adminSessionCookieValue: string;
27
+ adminSessionExpiresAt: Date;
28
+ }
29
+ export declare class ImpersonationService {
30
+ private readonly emFactory;
31
+ /** `auth`'s published session surface (feature 075, Phase C). */
32
+ private readonly sessionPort;
33
+ /** `customer_accounts`' published read model — the impersonation target. */
34
+ private readonly customerAccounts;
35
+ private readonly auditLog;
36
+ constructor(emFactory: () => EntityManager,
37
+ /** `auth`'s published session surface (feature 075, Phase C). */
38
+ sessionPort: AuthSessionPort,
39
+ /** `customer_accounts`' published read model — the impersonation target. */
40
+ customerAccounts: CustomerAccountReadPort, auditLog: AuditPort);
41
+ start(input: {
42
+ adminUserId: string;
43
+ /** Raw admin session cookie value — must be preserved as the shadow. */
44
+ adminSessionCookieValue: string;
45
+ customerAccountId: string;
46
+ /**
47
+ * Feature 040 — optional: standalone (org-less) customers have no
48
+ * Organization. When provided it scopes the target lookup; when omitted
49
+ * the target is found by id alone.
50
+ */
51
+ organizationId?: string | null;
52
+ reason?: string;
53
+ ip?: string;
54
+ userAgent?: string;
55
+ requestId?: string;
56
+ }): Promise<ImpersonationStartResult>;
57
+ end(input: {
58
+ impersonationSessionCookieValue: string;
59
+ adminShadowCookieValue: string;
60
+ ip?: string;
61
+ userAgent?: string;
62
+ requestId?: string;
63
+ }): Promise<ImpersonationEndResult>;
64
+ }
65
+ //# sourceMappingURL=impersonation-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"impersonation-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/impersonation-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC3B,MAAM,4BAA4B,CAAC;AAGpC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,wBAAwB;IACvC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,wBAAwB,EAAE,MAAM,CAAC;IACjC,sBAAsB,EAAE,IAAI,CAAC;IAC7B,6BAA6B,EAAE,MAAM,CAAC;IACtC,2BAA2B,EAAE,qBAAqB,CAAC;CACpD;AAED,MAAM,WAAW,sBAAsB;IACrC,uBAAuB,EAAE,MAAM,CAAC;IAChC,qBAAqB,EAAE,IAAI,CAAC;CAC7B;AAED,qBAAa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBALR,SAAS,EAAE,MAAM,aAAa;IAC/C,iEAAiE;IAChD,WAAW,EAAE,eAAe;IAC7C,4EAA4E;IAC3D,gBAAgB,EAAE,uBAAuB,EACzC,QAAQ,EAAE,SAAS;IAGhC,KAAK,CAAC,KAAK,EAAE;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,wEAAwE;QACxE,uBAAuB,EAAE,MAAM,CAAC;QAChC,iBAAiB,EAAE,MAAM,CAAC;QAC1B;;;;WAIG;QACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,wBAAwB,CAAC;IAsD/B,GAAG,CAAC,KAAK,EAAE;QACf,+BAA+B,EAAE,MAAM,CAAC;QACxC,sBAAsB,EAAE,MAAM,CAAC;QAC/B,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,sBAAsB,CAAC;CAmDpC"}
@@ -0,0 +1,103 @@
1
+ import { ERROR_CODES, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { AdminUser } from '../entities/admin-user.entity.js';
4
+ export class ImpersonationService {
5
+ emFactory;
6
+ sessionPort;
7
+ customerAccounts;
8
+ auditLog;
9
+ constructor(emFactory,
10
+ /** `auth`'s published session surface (feature 075, Phase C). */
11
+ sessionPort,
12
+ /** `customer_accounts`' published read model — the impersonation target. */
13
+ customerAccounts, auditLog) {
14
+ this.emFactory = emFactory;
15
+ this.sessionPort = sessionPort;
16
+ this.customerAccounts = customerAccounts;
17
+ this.auditLog = auditLog;
18
+ }
19
+ async start(input) {
20
+ const em = this.emFactory();
21
+ const admin = await em.findOne(AdminUser, { id: input.adminUserId, deletedAt: null });
22
+ if (!admin) {
23
+ throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'Admin session required.');
24
+ }
25
+ // `findInOrganization` is the two-argument form of the same lookup: the
26
+ // membership check and the identity read in one query, which is what stops
27
+ // the check being forgotten. `activeOnly` carries the `deletedAt: null`
28
+ // half of the read this replaced.
29
+ const target = input.organizationId != null
30
+ ? await this.customerAccounts.findInOrganization(input.customerAccountId, input.organizationId, { activeOnly: true })
31
+ : await this.customerAccounts.findById(input.customerAccountId, { activeOnly: true });
32
+ if (!target) {
33
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Target customer not found.');
34
+ }
35
+ // Audit FIRST — partial failure leaves no dangling cookie.
36
+ await this.auditLog.record({
37
+ actorAdminUserId: admin.id,
38
+ impersonatedCustomerAccountId: target.id,
39
+ action: 'impersonation.start',
40
+ objectType: 'customer_account',
41
+ objectId: target.id,
42
+ stateBefore: null,
43
+ stateAfter: { reason: input.reason ?? null, organizationId: input.organizationId ?? null },
44
+ ...(input.ip !== undefined ? { ipAddress: input.ip } : {}),
45
+ ...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
46
+ ...(input.requestId !== undefined ? { requestId: input.requestId } : {}),
47
+ });
48
+ const session = await this.sessionPort.createSession({
49
+ kind: 'impersonation',
50
+ customerAccountId: target.id,
51
+ impersonatorAdminUserId: admin.id,
52
+ ...(input.ip !== undefined ? { ipAddress: input.ip } : {}),
53
+ ...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
54
+ });
55
+ return {
56
+ impersonationSessionId: session.session.id,
57
+ impersonationCookieValue: session.cookieValue,
58
+ impersonationExpiresAt: session.expiresAt,
59
+ adminShadowSessionCookieValue: input.adminSessionCookieValue,
60
+ impersonatedCustomerAccount: target,
61
+ };
62
+ }
63
+ async end(input) {
64
+ // Resolve current impersonation session to get the admin id for the audit
65
+ // entry; then destroy it, then audit, then return the shadow as the new
66
+ // session cookie.
67
+ const resolved = await this.sessionPort.loadSession(input.impersonationSessionCookieValue);
68
+ if (!resolved ||
69
+ resolved.kind !== 'impersonation' ||
70
+ !resolved.session.impersonatorAdminUserId ||
71
+ !resolved.session.customerAccountId) {
72
+ throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'No active impersonation session.');
73
+ }
74
+ const adminId = resolved.session.impersonatorAdminUserId;
75
+ const customerId = resolved.session.customerAccountId;
76
+ // Destroy impersonation session — single use.
77
+ await this.sessionPort.destroySession(resolved.session.id);
78
+ await this.auditLog.record({
79
+ actorAdminUserId: adminId,
80
+ impersonatedCustomerAccountId: customerId,
81
+ action: 'impersonation.end',
82
+ objectType: 'customer_account',
83
+ objectId: customerId,
84
+ stateBefore: null,
85
+ stateAfter: null,
86
+ ...(input.ip !== undefined ? { ipAddress: input.ip } : {}),
87
+ ...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
88
+ ...(input.requestId !== undefined ? { requestId: input.requestId } : {}),
89
+ });
90
+ // The shadow cookie is the unchanged original admin session — verify it
91
+ // still resolves before handing it back. If somehow the admin session
92
+ // expired during impersonation, return 401 so the client must re-login.
93
+ const shadow = await this.sessionPort.loadSession(input.adminShadowCookieValue);
94
+ if (!shadow || shadow.kind !== 'admin') {
95
+ throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, 'Original admin session is no longer valid; please log in again.');
96
+ }
97
+ return {
98
+ adminSessionCookieValue: input.adminShadowCookieValue,
99
+ adminSessionExpiresAt: shadow.session.expiresAt,
100
+ };
101
+ }
102
+ }
103
+ //# sourceMappingURL=impersonation-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"impersonation-service.js","sourceRoot":"","sources":["../../../src/backend/services/impersonation-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Which admin users hold a second factor — the module's own view of a fact
3
+ * `mfa` owns.
4
+ *
5
+ * A function type rather than an interface, and declared here rather than
6
+ * shared with `customer_accounts`, because there is nothing to share: the
7
+ * published shape is `MfaEnrolmentStatePort` in `@endora-commerce/contracts`,
8
+ * and this is only the shape in which *this* module's serializers want the
9
+ * answer — the `subjectType` argument is already decided, and a `Set` is what a
10
+ * list surface asks per row.
11
+ *
12
+ * `backend.ts` builds the one implementation: it decides `mfa`'s presence and
13
+ * then resolves the port, so an absent owner produces an empty set rather than
14
+ * a `ModuleDisabledError` nobody could answer on an admin list. That is a
15
+ * declared degrade — see the `degrades-without` entry for `mfaEnrolmentStatePort`
16
+ * in this module's manifest — and not a caught gate.
17
+ */
18
+ export type TwoFactorEnrolmentReader = (adminUserIds: readonly string[]) => Promise<ReadonlySet<string>>;
19
+ //# sourceMappingURL=two-factor-enrolments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"two-factor-enrolments.d.ts","sourceRoot":"","sources":["../../../src/backend/services/two-factor-enrolments.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,wBAAwB,GAAG,CACrC,YAAY,EAAE,SAAS,MAAM,EAAE,KAC5B,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=two-factor-enrolments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"two-factor-enrolments.js","sourceRoot":"","sources":["../../../src/backend/services/two-factor-enrolments.ts"],"names":[],"mappings":""}