@byline/admin 2.4.0 → 2.4.1

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 (177) hide show
  1. package/dist/abilities.js +5 -24
  2. package/dist/index.js +8 -30
  3. package/dist/lib/assert-admin-actor.js +13 -74
  4. package/dist/lib/create-command.js +6 -16
  5. package/dist/modules/admin-account/commands.js +35 -24
  6. package/dist/modules/admin-account/components/change-password.d.ts +8 -0
  7. package/dist/modules/admin-account/components/change-password.js +192 -0
  8. package/dist/modules/admin-account/components/change-password.module.js +8 -0
  9. package/dist/modules/admin-account/components/change-password_module.css +27 -0
  10. package/dist/modules/admin-account/components/container.d.ts +29 -0
  11. package/dist/modules/admin-account/components/container.js +298 -0
  12. package/dist/modules/admin-account/components/container.module.js +28 -0
  13. package/dist/modules/admin-account/components/container_module.css +106 -0
  14. package/dist/modules/admin-account/components/update.d.ts +8 -0
  15. package/dist/modules/admin-account/components/update.js +207 -0
  16. package/dist/modules/admin-account/components/update.module.js +8 -0
  17. package/dist/modules/admin-account/components/update_module.css +27 -0
  18. package/dist/modules/admin-account/errors.js +14 -45
  19. package/dist/modules/admin-account/index.js +4 -34
  20. package/dist/modules/admin-account/schemas.js +25 -59
  21. package/dist/modules/admin-account/service.js +56 -61
  22. package/dist/modules/admin-permissions/abilities.js +6 -24
  23. package/dist/modules/admin-permissions/commands.js +42 -28
  24. package/dist/modules/admin-permissions/components/inspector.d.ts +4 -0
  25. package/dist/modules/admin-permissions/components/inspector.js +284 -0
  26. package/dist/modules/admin-permissions/components/inspector.module.js +56 -0
  27. package/dist/modules/admin-permissions/components/inspector_module.css +238 -0
  28. package/dist/modules/admin-permissions/dto.js +3 -16
  29. package/dist/modules/admin-permissions/errors.js +14 -27
  30. package/dist/modules/admin-permissions/index.js +6 -26
  31. package/dist/modules/admin-permissions/repository.js +1 -8
  32. package/dist/modules/admin-permissions/schemas.js +33 -70
  33. package/dist/modules/admin-permissions/service.js +88 -92
  34. package/dist/modules/admin-roles/abilities.js +8 -30
  35. package/dist/modules/admin-roles/commands.js +89 -55
  36. package/dist/modules/admin-roles/components/create.d.ts +7 -0
  37. package/dist/modules/admin-roles/components/create.js +177 -0
  38. package/dist/modules/admin-roles/components/create.module.js +8 -0
  39. package/dist/modules/admin-roles/components/create_module.css +27 -0
  40. package/dist/modules/admin-roles/components/permissions.d.ts +10 -0
  41. package/dist/modules/admin-roles/components/permissions.js +303 -0
  42. package/dist/modules/admin-roles/components/permissions.module.js +44 -0
  43. package/dist/modules/admin-roles/components/permissions_module.css +192 -0
  44. package/dist/modules/admin-roles/components/update.d.ts +8 -0
  45. package/dist/modules/admin-roles/components/update.js +166 -0
  46. package/dist/modules/admin-roles/components/update.module.js +8 -0
  47. package/dist/modules/admin-roles/components/update_module.css +27 -0
  48. package/dist/modules/admin-roles/dto.js +3 -16
  49. package/dist/modules/admin-roles/errors.js +16 -40
  50. package/dist/modules/admin-roles/index.js +6 -26
  51. package/dist/modules/admin-roles/repository.js +1 -8
  52. package/dist/modules/admin-roles/schemas.js +41 -71
  53. package/dist/modules/admin-roles/service.js +79 -82
  54. package/dist/modules/admin-users/abilities.js +9 -38
  55. package/dist/modules/admin-users/commands.js +92 -50
  56. package/dist/modules/admin-users/components/create.d.ts +8 -0
  57. package/dist/modules/admin-users/components/create.js +268 -0
  58. package/dist/modules/admin-users/components/create.module.js +10 -0
  59. package/dist/modules/admin-users/components/create_module.css +45 -0
  60. package/dist/modules/admin-users/components/roles.d.ts +11 -0
  61. package/dist/modules/admin-users/components/roles.js +148 -0
  62. package/dist/modules/admin-users/components/roles.module.js +18 -0
  63. package/dist/modules/admin-users/components/roles_module.css +75 -0
  64. package/dist/modules/admin-users/components/set-password.d.ts +8 -0
  65. package/dist/modules/admin-users/components/set-password.js +170 -0
  66. package/dist/modules/admin-users/components/set-password.module.js +9 -0
  67. package/dist/modules/admin-users/components/set-password_module.css +31 -0
  68. package/dist/modules/admin-users/components/update.d.ts +8 -0
  69. package/dist/modules/admin-users/components/update.js +254 -0
  70. package/dist/modules/admin-users/components/update.module.js +9 -0
  71. package/dist/modules/admin-users/components/update_module.css +34 -0
  72. package/dist/modules/admin-users/dto.js +3 -18
  73. package/dist/modules/admin-users/errors.js +17 -43
  74. package/dist/modules/admin-users/index.js +7 -27
  75. package/dist/modules/admin-users/repository.js +1 -8
  76. package/dist/modules/admin-users/schemas.js +44 -75
  77. package/dist/modules/admin-users/seed-super-admin.js +9 -34
  78. package/dist/modules/admin-users/service.js +76 -91
  79. package/dist/modules/auth/components/sign-in-form.d.ts +12 -0
  80. package/dist/modules/auth/components/sign-in-form.js +115 -0
  81. package/dist/modules/auth/components/sign-in-form.module.js +12 -0
  82. package/dist/modules/auth/components/sign-in-form_module.css +41 -0
  83. package/dist/modules/auth/index.js +3 -24
  84. package/dist/modules/auth/jwt-session-provider.js +179 -149
  85. package/dist/modules/auth/password.js +11 -53
  86. package/dist/modules/auth/phc.js +21 -54
  87. package/dist/modules/auth/refresh-tokens-repository.js +1 -8
  88. package/dist/modules/auth/resolve-actor.js +6 -28
  89. package/dist/services/admin-services-context.d.ts +16 -0
  90. package/dist/services/admin-services-context.js +13 -0
  91. package/dist/services/admin-services-types.d.ts +129 -0
  92. package/dist/services/admin-services-types.js +1 -0
  93. package/dist/store.js +1 -8
  94. package/dist/vendor/noble-argon2/_blake.js +277 -45
  95. package/dist/vendor/noble-argon2/_md.js +81 -136
  96. package/dist/vendor/noble-argon2/_u64.js +65 -67
  97. package/dist/vendor/noble-argon2/argon2.js +181 -342
  98. package/dist/vendor/noble-argon2/blake2.js +252 -327
  99. package/dist/vendor/noble-argon2/utils.js +110 -490
  100. package/dist/vendor/noble-argon2/utils.js.LICENSE.txt +1 -0
  101. package/package.json +89 -10
  102. package/src/abilities.ts +32 -0
  103. package/src/declarations.d.ts +4 -0
  104. package/src/index.ts +39 -0
  105. package/src/lib/assert-admin-actor.ts +90 -0
  106. package/src/lib/create-command.ts +109 -0
  107. package/src/modules/admin-account/commands.ts +76 -0
  108. package/src/modules/admin-account/components/change-password.module.css +40 -0
  109. package/src/modules/admin-account/components/change-password.tsx +232 -0
  110. package/src/modules/admin-account/components/container.module.css +158 -0
  111. package/src/modules/admin-account/components/container.tsx +229 -0
  112. package/src/modules/admin-account/components/update.module.css +40 -0
  113. package/src/modules/admin-account/components/update.tsx +263 -0
  114. package/src/modules/admin-account/errors.ts +75 -0
  115. package/src/modules/admin-account/index.ts +60 -0
  116. package/src/modules/admin-account/schemas.ts +84 -0
  117. package/src/modules/admin-account/service.ts +92 -0
  118. package/src/modules/admin-permissions/abilities.ts +46 -0
  119. package/src/modules/admin-permissions/commands.ts +103 -0
  120. package/src/modules/admin-permissions/components/inspector.module.css +326 -0
  121. package/src/modules/admin-permissions/components/inspector.tsx +298 -0
  122. package/src/modules/admin-permissions/dto.ts +28 -0
  123. package/src/modules/admin-permissions/errors.ts +57 -0
  124. package/src/modules/admin-permissions/index.ts +72 -0
  125. package/src/modules/admin-permissions/repository.ts +49 -0
  126. package/src/modules/admin-permissions/schemas.ts +128 -0
  127. package/src/modules/admin-permissions/service.ts +137 -0
  128. package/src/modules/admin-roles/abilities.ts +62 -0
  129. package/src/modules/admin-roles/commands.ts +161 -0
  130. package/src/modules/admin-roles/components/create.module.css +40 -0
  131. package/src/modules/admin-roles/components/create.tsx +218 -0
  132. package/src/modules/admin-roles/components/permissions.module.css +279 -0
  133. package/src/modules/admin-roles/components/permissions.tsx +396 -0
  134. package/src/modules/admin-roles/components/update.module.css +40 -0
  135. package/src/modules/admin-roles/components/update.tsx +218 -0
  136. package/src/modules/admin-roles/dto.ts +30 -0
  137. package/src/modules/admin-roles/errors.ts +76 -0
  138. package/src/modules/admin-roles/index.ts +81 -0
  139. package/src/modules/admin-roles/repository.ts +96 -0
  140. package/src/modules/admin-roles/schemas.ts +139 -0
  141. package/src/modules/admin-roles/service.ts +136 -0
  142. package/src/modules/admin-users/abilities.ts +76 -0
  143. package/src/modules/admin-users/commands.ts +157 -0
  144. package/src/modules/admin-users/components/create.module.css +63 -0
  145. package/src/modules/admin-users/components/create.tsx +323 -0
  146. package/src/modules/admin-users/components/roles.module.css +119 -0
  147. package/src/modules/admin-users/components/roles.tsx +172 -0
  148. package/src/modules/admin-users/components/set-password.module.css +46 -0
  149. package/src/modules/admin-users/components/set-password.tsx +199 -0
  150. package/src/modules/admin-users/components/update.module.css +49 -0
  151. package/src/modules/admin-users/components/update.tsx +328 -0
  152. package/src/modules/admin-users/dto.ts +39 -0
  153. package/src/modules/admin-users/errors.ts +84 -0
  154. package/src/modules/admin-users/index.ts +91 -0
  155. package/src/modules/admin-users/repository.ts +161 -0
  156. package/src/modules/admin-users/schemas.ts +168 -0
  157. package/src/modules/admin-users/seed-super-admin.ts +102 -0
  158. package/src/modules/admin-users/service.ts +166 -0
  159. package/src/modules/auth/components/sign-in-form.module.css +62 -0
  160. package/src/modules/auth/components/sign-in-form.tsx +132 -0
  161. package/src/modules/auth/index.ts +31 -0
  162. package/src/modules/auth/jwt-session-provider.ts +301 -0
  163. package/src/modules/auth/password.ts +94 -0
  164. package/src/modules/auth/phc.ts +121 -0
  165. package/src/modules/auth/refresh-tokens-repository.ts +74 -0
  166. package/src/modules/auth/resolve-actor.ts +42 -0
  167. package/src/services/admin-services-context.tsx +52 -0
  168. package/src/services/admin-services-types.ts +177 -0
  169. package/src/store.ts +32 -0
  170. package/src/vendor/noble-argon2/LICENSE +21 -0
  171. package/src/vendor/noble-argon2/README.md +87 -0
  172. package/src/vendor/noble-argon2/_blake.ts +58 -0
  173. package/src/vendor/noble-argon2/_md.ts +223 -0
  174. package/src/vendor/noble-argon2/_u64.ts +118 -0
  175. package/src/vendor/noble-argon2/argon2.ts +668 -0
  176. package/src/vendor/noble-argon2/blake2.ts +583 -0
  177. package/src/vendor/noble-argon2/utils.ts +849 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * `@byline/admin/admin-users` — admin user CRUD.
11
+ *
12
+ * Exports the adapter-facing `AdminUsersRepository` contract, ability
13
+ * keys, transport-agnostic commands, the `AdminUsersService`, the seed
14
+ * helper, and the module's error types. Commands are the recommended
15
+ * entry point for any caller; the service is exposed for internal uses
16
+ * (seeds, other services) that want to skip Zod/ability overhead.
17
+ *
18
+ * Password hashing is owned by `@byline/admin/auth`; this module takes
19
+ * pre-hashed `password_hash` strings on the repository boundary so the
20
+ * adapter never sees plaintext.
21
+ */
22
+
23
+ export {
24
+ ADMIN_USERS_ABILITIES,
25
+ type AdminUsersAbilityKey,
26
+ registerAdminUsersAbilities,
27
+ } from './abilities.js'
28
+ export {
29
+ createAdminUserCommand,
30
+ deleteAdminUserCommand,
31
+ disableAdminUserCommand,
32
+ enableAdminUserCommand,
33
+ getAdminUserCommand,
34
+ listAdminUsersCommand,
35
+ setAdminUserPasswordCommand,
36
+ updateAdminUserCommand,
37
+ } from './commands.js'
38
+ export { toAdminUser } from './dto.js'
39
+ export {
40
+ AdminUsersError,
41
+ type AdminUsersErrorCode,
42
+ AdminUsersErrorCodes,
43
+ ERR_ADMIN_USER_EMAIL_IN_USE,
44
+ ERR_ADMIN_USER_NOT_FOUND,
45
+ ERR_ADMIN_USER_SELF_DELETE,
46
+ ERR_ADMIN_USER_SELF_DISABLE,
47
+ ERR_ADMIN_USER_VERSION_CONFLICT,
48
+ } from './errors.js'
49
+ export {
50
+ adminUserListResponseSchema,
51
+ adminUserResponseSchema,
52
+ createAdminUserRequestSchema,
53
+ deleteAdminUserRequestSchema,
54
+ disableAdminUserRequestSchema,
55
+ enableAdminUserRequestSchema,
56
+ getAdminUserRequestSchema,
57
+ listAdminUsersRequestSchema,
58
+ okResponseSchema,
59
+ setAdminUserPasswordRequestSchema,
60
+ updateAdminUserRequestSchema,
61
+ } from './schemas.js'
62
+ export {
63
+ type SeedSuperAdminInput,
64
+ type SeedSuperAdminResult,
65
+ seedSuperAdmin,
66
+ } from './seed-super-admin.js'
67
+ export { AdminUsersService } from './service.js'
68
+ export type { AdminUsersCommandDeps } from './commands.js'
69
+ export type {
70
+ AdminUserListOrder,
71
+ AdminUserRow,
72
+ AdminUsersRepository,
73
+ AdminUserWithPasswordRow,
74
+ CountAdminUsersOptions,
75
+ CreateAdminUserInput,
76
+ ListAdminUsersOptions,
77
+ UpdateAdminUserInput,
78
+ } from './repository.js'
79
+ export type {
80
+ AdminUserListResponse,
81
+ AdminUserResponse,
82
+ CreateAdminUserRequest,
83
+ DeleteAdminUserRequest,
84
+ DisableAdminUserRequest,
85
+ EnableAdminUserRequest,
86
+ GetAdminUserRequest,
87
+ ListAdminUsersRequest,
88
+ OkResponse,
89
+ SetAdminUserPasswordRequest,
90
+ UpdateAdminUserRequest,
91
+ } from './schemas.js'
@@ -0,0 +1,161 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * `AdminUsersRepository` — the DB-adapter-facing contract for the
11
+ * `byline_admin_users` table.
12
+ *
13
+ * The interface deliberately takes **pre-hashed** password strings
14
+ * (`password_hash`) rather than plaintext. Argon2 / bcrypt hashing is a
15
+ * service-layer concern that depends on `@byline/admin/auth` primitives;
16
+ * keeping it out of the repository means the adapter stays unaware of
17
+ * password policy and the hashing library of the day.
18
+ *
19
+ * **Optimistic concurrency.** Content-shaped writes (`update`,
20
+ * `setPasswordHash`, `delete`) take an `expectedVid` and bump the stored
21
+ * `vid` on success. If the stored `vid` does not match `expectedVid` the
22
+ * adapter throws `AdminUsersError(VERSION_CONFLICT)`, signalling a stale
23
+ * client. Admin-intent writes that do not depend on current state
24
+ * (`setEnabled`, login counters) are vid-less — last-writer-wins is the
25
+ * right semantic for those.
26
+ *
27
+ * Adapters (e.g. `@byline/db-postgres`) implement this interface; admin
28
+ * services (`seed-super-admin`, admin-user commands) consume it. No
29
+ * caller should ever construct `AdminUsersRepository` instances directly
30
+ * outside the adapter — use the `AdminStore` bundle passed at
31
+ * `initBylineCore()` time.
32
+ */
33
+
34
+ /**
35
+ * Public-facing admin-user row — the `password_hash` column is
36
+ * deliberately omitted. Only `getByEmailForSignIn` returns the hash, and
37
+ * only so the session provider can verify it.
38
+ */
39
+ export interface AdminUserRow {
40
+ id: string
41
+ vid: number
42
+ given_name: string | null
43
+ family_name: string | null
44
+ username: string | null
45
+ email: string
46
+ remember_me: boolean
47
+ last_login: Date | null
48
+ last_login_ip: string | null
49
+ failed_login_attempts: number
50
+ is_super_admin: boolean
51
+ is_enabled: boolean
52
+ is_email_verified: boolean
53
+ created_at: Date
54
+ updated_at: Date
55
+ }
56
+
57
+ /**
58
+ * Admin-user row including the PHC password hash. Returned only by
59
+ * `getByEmailForSignIn` — callers must treat it with care (never log,
60
+ * never return to clients).
61
+ */
62
+ export interface AdminUserWithPasswordRow extends AdminUserRow {
63
+ password_hash: string
64
+ }
65
+
66
+ export interface CreateAdminUserInput {
67
+ email: string
68
+ /** Pre-hashed PHC string. Service layer hashes plaintext before calling. */
69
+ password_hash: string
70
+ given_name?: string | null
71
+ family_name?: string | null
72
+ username?: string | null
73
+ is_super_admin?: boolean
74
+ is_enabled?: boolean
75
+ is_email_verified?: boolean
76
+ }
77
+
78
+ export interface UpdateAdminUserInput {
79
+ given_name?: string | null
80
+ family_name?: string | null
81
+ username?: string | null
82
+ email?: string
83
+ is_super_admin?: boolean
84
+ is_enabled?: boolean
85
+ is_email_verified?: boolean
86
+ remember_me?: boolean
87
+ }
88
+
89
+ export type AdminUserListOrder =
90
+ | 'given_name'
91
+ | 'family_name'
92
+ | 'email'
93
+ | 'username'
94
+ | 'created_at'
95
+ | 'updated_at'
96
+
97
+ export interface ListAdminUsersOptions {
98
+ /** 1-based page number. */
99
+ page: number
100
+ /** Page size. Reasonable ceiling applied at the command layer. */
101
+ pageSize: number
102
+ /** Free-text search across email, given_name, family_name, username. */
103
+ query?: string
104
+ /** Column to sort by. */
105
+ order: AdminUserListOrder
106
+ /** True for DESC, false for ASC. */
107
+ desc: boolean
108
+ }
109
+
110
+ export interface CountAdminUsersOptions {
111
+ /** Free-text search — same semantics as `list`. */
112
+ query?: string
113
+ }
114
+
115
+ export interface AdminUsersRepository {
116
+ create(input: CreateAdminUserInput): Promise<AdminUserRow>
117
+ getById(id: string): Promise<AdminUserRow | null>
118
+ getByEmail(email: string): Promise<AdminUserRow | null>
119
+ getByUsername(username: string): Promise<AdminUserRow | null>
120
+ /**
121
+ * Sign-in-only lookup. Returns the PHC hash alongside the public row so
122
+ * the session provider can verify. Callers **must not** persist or echo
123
+ * the `password_hash` field.
124
+ */
125
+ getByEmailForSignIn(email: string): Promise<AdminUserWithPasswordRow | null>
126
+ /**
127
+ * Authenticated-verification lookup. Same shape as
128
+ * `getByEmailForSignIn` but keyed by id — used by the self-service
129
+ * change-password flow, where the actor is already authenticated and
130
+ * we need to verify the *current* password before swapping in a new
131
+ * one. Same handling rules apply: callers **must not** persist or
132
+ * echo the `password_hash` field.
133
+ */
134
+ getByIdForSignIn(id: string): Promise<AdminUserWithPasswordRow | null>
135
+ /** Paginated, filtered, sorted list. */
136
+ list(options: ListAdminUsersOptions): Promise<AdminUserRow[]>
137
+ /** Total row count matching the same filter (for pager `total_pages`). */
138
+ count(options?: CountAdminUsersOptions): Promise<number>
139
+ /**
140
+ * Content update with optimistic concurrency. Throws
141
+ * `AdminUsersError(VERSION_CONFLICT)` if the stored `vid` differs from
142
+ * `expectedVid`. Bumps `vid` on success and returns the fresh row.
143
+ */
144
+ update(id: string, expectedVid: number, patch: UpdateAdminUserInput): Promise<AdminUserRow>
145
+ /**
146
+ * Replace the stored password hash with optimistic concurrency.
147
+ * Version-gated on `expectedVid`. Caller supplies a pre-hashed PHC string.
148
+ * Returns the updated row so callers holding the edit form can refresh
149
+ * their cached `vid` without a second round-trip.
150
+ */
151
+ setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise<AdminUserRow>
152
+ /** Toggle enabled state. Vid-less — admin intent is independent of other edits. */
153
+ setEnabled(id: string, enabled: boolean): Promise<void>
154
+ recordLoginSuccess(id: string, ip: string | null): Promise<void>
155
+ recordLoginFailure(id: string): Promise<void>
156
+ /**
157
+ * Delete with optimistic concurrency. Version-gated on `expectedVid` to
158
+ * prevent races against a concurrent update.
159
+ */
160
+ delete(id: string, expectedVid: number): Promise<void>
161
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { passwordSchema, uuidSchema } from '@byline/core/validation'
10
+ import { z } from 'zod'
11
+
12
+ /**
13
+ * Zod request/response schemas for the admin-users commands.
14
+ *
15
+ * Both input and output are validated — response validation keeps the
16
+ * admin surface honest about what it promises downstream clients. The
17
+ * DTO shaper in `dto.ts` produces values that match `adminUserResponseSchema`
18
+ * exactly; if the schema or the DTO drifts, tests catch it at the
19
+ * command boundary.
20
+ *
21
+ * `vid` is the optimistic-concurrency version — every write that touches
22
+ * content content takes the client-held `vid` and the adapter gates the
23
+ * write on it, throwing `ADMIN_USER_VERSION_CONFLICT` on mismatch.
24
+ *
25
+ * Password and uuid helpers come from `@byline/core/validation` — the
26
+ * shared primitives ported from the organisation's `@infonomic/shared`
27
+ * schemas so rules stay consistent across projects.
28
+ */
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Field-level schemas (re-used across requests)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const idSchema = uuidSchema
35
+
36
+ const vidSchema = z
37
+ .number({ message: 'vid is required' })
38
+ .int({ message: 'vid must be an integer' })
39
+ .positive({ message: 'vid must be positive' })
40
+
41
+ const emailSchema = z
42
+ .email({ message: 'email must be a valid address' })
43
+ .min(3)
44
+ .max(254)
45
+ .transform((v) => v.toLowerCase())
46
+
47
+ const nameSchema = z.string().min(1).max(100)
48
+
49
+ const orderSchema = z.enum([
50
+ 'given_name',
51
+ 'family_name',
52
+ 'email',
53
+ 'username',
54
+ 'created_at',
55
+ 'updated_at',
56
+ ])
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Requests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export const listAdminUsersRequestSchema = z.object({
63
+ page: z.number().int().min(1).optional().default(1),
64
+ pageSize: z.number().int().min(1).max(100).optional().default(20),
65
+ query: z.string().max(128).optional(),
66
+ order: orderSchema.optional().default('created_at'),
67
+ desc: z.boolean().optional().default(true),
68
+ })
69
+ export type ListAdminUsersRequest = z.infer<typeof listAdminUsersRequestSchema>
70
+
71
+ export const getAdminUserRequestSchema = z.object({
72
+ id: idSchema,
73
+ })
74
+ export type GetAdminUserRequest = z.infer<typeof getAdminUserRequestSchema>
75
+
76
+ export const createAdminUserRequestSchema = z.object({
77
+ email: emailSchema,
78
+ password: passwordSchema,
79
+ given_name: nameSchema.nullish(),
80
+ family_name: nameSchema.nullish(),
81
+ username: z.string().min(1).max(100).nullish(),
82
+ is_super_admin: z.boolean().optional(),
83
+ is_enabled: z.boolean().optional(),
84
+ is_email_verified: z.boolean().optional(),
85
+ })
86
+ export type CreateAdminUserRequest = z.infer<typeof createAdminUserRequestSchema>
87
+
88
+ export const updateAdminUserRequestSchema = z.object({
89
+ id: idSchema,
90
+ vid: vidSchema,
91
+ patch: z
92
+ .object({
93
+ email: emailSchema.optional(),
94
+ given_name: nameSchema.nullish(),
95
+ family_name: nameSchema.nullish(),
96
+ username: z.string().min(1).max(100).nullish(),
97
+ is_super_admin: z.boolean().optional(),
98
+ is_enabled: z.boolean().optional(),
99
+ is_email_verified: z.boolean().optional(),
100
+ })
101
+ .refine((p) => Object.keys(p).length > 0, { message: 'patch cannot be empty' }),
102
+ })
103
+ export type UpdateAdminUserRequest = z.infer<typeof updateAdminUserRequestSchema>
104
+
105
+ export const setAdminUserPasswordRequestSchema = z.object({
106
+ id: idSchema,
107
+ vid: vidSchema,
108
+ password: passwordSchema,
109
+ })
110
+ export type SetAdminUserPasswordRequest = z.infer<typeof setAdminUserPasswordRequestSchema>
111
+
112
+ export const enableAdminUserRequestSchema = z.object({ id: idSchema })
113
+ export type EnableAdminUserRequest = z.infer<typeof enableAdminUserRequestSchema>
114
+
115
+ export const disableAdminUserRequestSchema = z.object({ id: idSchema })
116
+ export type DisableAdminUserRequest = z.infer<typeof disableAdminUserRequestSchema>
117
+
118
+ export const deleteAdminUserRequestSchema = z.object({
119
+ id: idSchema,
120
+ vid: vidSchema,
121
+ })
122
+ export type DeleteAdminUserRequest = z.infer<typeof deleteAdminUserRequestSchema>
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Responses
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Public shape of an admin user. Deliberately excludes `password_hash` —
130
+ * the DTO in `dto.ts` is responsible for producing exactly this shape
131
+ * from an `AdminUserRow`, so the schema acts as a contract check.
132
+ */
133
+ export const adminUserResponseSchema = z.object({
134
+ id: z.string(),
135
+ vid: z.number().int(),
136
+ email: z.string(),
137
+ given_name: z.string().nullable(),
138
+ family_name: z.string().nullable(),
139
+ username: z.string().nullable(),
140
+ remember_me: z.boolean(),
141
+ last_login: z.date().nullable(),
142
+ last_login_ip: z.string().nullable(),
143
+ failed_login_attempts: z.number().int(),
144
+ is_super_admin: z.boolean(),
145
+ is_enabled: z.boolean(),
146
+ is_email_verified: z.boolean(),
147
+ created_at: z.date(),
148
+ updated_at: z.date(),
149
+ })
150
+ export type AdminUserResponse = z.infer<typeof adminUserResponseSchema>
151
+
152
+ export const adminUserListResponseSchema = z.object({
153
+ users: z.array(adminUserResponseSchema),
154
+ meta: z.object({
155
+ total: z.number().int().min(0),
156
+ total_pages: z.number().int().min(0),
157
+ page: z.number().int().min(1),
158
+ page_size: z.number().int().min(1),
159
+ query: z.string(),
160
+ order: orderSchema,
161
+ desc: z.boolean(),
162
+ }),
163
+ })
164
+ export type AdminUserListResponse = z.infer<typeof adminUserListResponseSchema>
165
+
166
+ /** Empty response for void-returning mutations (set-password, enable, disable, delete). */
167
+ export const okResponseSchema = z.object({ ok: z.literal(true) })
168
+ export type OkResponse = z.infer<typeof okResponseSchema>
@@ -0,0 +1,102 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { hashPassword } from '../auth/password.js'
10
+ import type { AdminStore } from '../../store.js'
11
+
12
+ export interface SeedSuperAdminInput {
13
+ email: string
14
+ /** Plaintext — hashed before insert. */
15
+ password: string
16
+ given_name?: string
17
+ family_name?: string
18
+ /** Role machine_name. Defaults to `'super-admin'`. */
19
+ roleMachineName?: string
20
+ /** Role display name. Defaults to `'Super Admin'`. */
21
+ roleName?: string
22
+ }
23
+
24
+ export interface SeedSuperAdminResult {
25
+ userId: string
26
+ roleId: string
27
+ created: {
28
+ user: boolean
29
+ role: boolean
30
+ assignment: boolean
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Idempotently create the super-admin role + user and assign them to each
36
+ * other. Safe to re-run against an existing database — reports what was
37
+ * newly created via the `created` flags so scripts can log meaningfully.
38
+ *
39
+ * This is the only built-in seed we ship for auth. Everything else is
40
+ * configured through the admin UI (or directly via the repositories) once
41
+ * the super-admin is in.
42
+ *
43
+ * The user row has `is_super_admin: true` and `is_enabled: true` set
44
+ * explicitly — the default for `is_enabled` is false so UI-created
45
+ * accounts require deliberate enablement, but the seed always produces a
46
+ * usable account.
47
+ */
48
+ export async function seedSuperAdmin(
49
+ store: AdminStore,
50
+ input: SeedSuperAdminInput
51
+ ): Promise<SeedSuperAdminResult> {
52
+ const roleMachineName = input.roleMachineName ?? 'super-admin'
53
+ const roleName = input.roleName ?? 'Super Admin'
54
+
55
+ // 1. Role
56
+ let role = await store.adminRoles.getByMachineName(roleMachineName)
57
+ let roleCreated = false
58
+ if (!role) {
59
+ role = await store.adminRoles.create({
60
+ name: roleName,
61
+ machine_name: roleMachineName,
62
+ description:
63
+ 'Built-in role held by the initial super-admin. Individual users also carry the is_super_admin flag.',
64
+ order: 0,
65
+ })
66
+ roleCreated = true
67
+ }
68
+
69
+ // 2. User
70
+ let user = await store.adminUsers.getByEmail(input.email)
71
+ let userCreated = false
72
+ if (!user) {
73
+ const passwordHash = await hashPassword(input.password)
74
+ user = await store.adminUsers.create({
75
+ email: input.email,
76
+ password_hash: passwordHash,
77
+ given_name: input.given_name ?? null,
78
+ family_name: input.family_name ?? null,
79
+ is_super_admin: true,
80
+ is_enabled: true,
81
+ is_email_verified: true,
82
+ })
83
+ userCreated = true
84
+ }
85
+
86
+ // 3. Assignment (idempotent)
87
+ const existingRoles = await store.adminRoles.listRolesForUser(user.id)
88
+ const alreadyAssigned = existingRoles.some((r) => r.id === role.id)
89
+ if (!alreadyAssigned) {
90
+ await store.adminRoles.assignToUser(role.id, user.id)
91
+ }
92
+
93
+ return {
94
+ userId: user.id,
95
+ roleId: role.id,
96
+ created: {
97
+ user: userCreated,
98
+ role: roleCreated,
99
+ assignment: !alreadyAssigned,
100
+ },
101
+ }
102
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { AdminAuth } from '@byline/auth'
10
+
11
+ import { hashPassword } from '../auth/password.js'
12
+ import { toAdminUser } from './dto.js'
13
+ import {
14
+ ERR_ADMIN_USER_EMAIL_IN_USE,
15
+ ERR_ADMIN_USER_NOT_FOUND,
16
+ ERR_ADMIN_USER_SELF_DELETE,
17
+ ERR_ADMIN_USER_SELF_DISABLE,
18
+ } from './errors.js'
19
+ import type { AdminUsersRepository } from './repository.js'
20
+ import type {
21
+ AdminUserListResponse,
22
+ AdminUserResponse,
23
+ CreateAdminUserRequest,
24
+ DeleteAdminUserRequest,
25
+ DisableAdminUserRequest,
26
+ EnableAdminUserRequest,
27
+ GetAdminUserRequest,
28
+ ListAdminUsersRequest,
29
+ SetAdminUserPasswordRequest,
30
+ UpdateAdminUserRequest,
31
+ } from './schemas.js'
32
+
33
+ /**
34
+ * Business logic for administering admin users.
35
+ *
36
+ * The service owns four concerns the repository deliberately avoids:
37
+ *
38
+ * 1. **Password hashing.** `hashPassword` from `@byline/admin/auth`
39
+ * runs here so every write path (create, setPassword, future
40
+ * password-reset flows) hashes consistently.
41
+ * 2. **Domain invariants.** Email conflict detection on create/update,
42
+ * self-delete / self-disable prevention — rules the database
43
+ * cannot enforce on its own.
44
+ * 3. **DTO shaping.** Raw rows are shaped through `toAdminUser` so
45
+ * the response contract is owned in one place.
46
+ * 4. **Optimistic-concurrency plumbing.** The repo gates writes on
47
+ * `expectedVid`; the service just threads it from the validated
48
+ * request shape. Version conflicts surface as
49
+ * `AdminUsersError(VERSION_CONFLICT)` from the adapter; the service
50
+ * does not catch them.
51
+ *
52
+ * Commands call service methods after Zod-validating input and asserting
53
+ * abilities; internal callers (seeds, other services) can call service
54
+ * methods directly. Either way, the service is transport-agnostic.
55
+ *
56
+ * Service methods take the acting `AdminAuth` as an explicit first
57
+ * argument when they need it for invariants (self-delete checks). Reads
58
+ * do not need the actor — the ability check at the command boundary is
59
+ * sufficient.
60
+ */
61
+ export class AdminUsersService {
62
+ readonly #repo: AdminUsersRepository
63
+
64
+ constructor(deps: { repo: AdminUsersRepository }) {
65
+ this.#repo = deps.repo
66
+ }
67
+
68
+ async listUsers(request: ListAdminUsersRequest): Promise<AdminUserListResponse> {
69
+ // Run list + count in parallel — they hit the same indexes but
70
+ // there's no reason to serialise them.
71
+ const [rows, total] = await Promise.all([
72
+ this.#repo.list({
73
+ page: request.page,
74
+ pageSize: request.pageSize,
75
+ query: request.query,
76
+ order: request.order,
77
+ desc: request.desc,
78
+ }),
79
+ this.#repo.count({ query: request.query }),
80
+ ])
81
+ const total_pages = Math.max(1, Math.ceil(total / request.pageSize))
82
+ return {
83
+ users: rows.map(toAdminUser),
84
+ meta: {
85
+ total,
86
+ total_pages,
87
+ page: request.page,
88
+ page_size: request.pageSize,
89
+ query: request.query ?? '',
90
+ order: request.order,
91
+ desc: request.desc,
92
+ },
93
+ }
94
+ }
95
+
96
+ async getUser(request: GetAdminUserRequest): Promise<AdminUserResponse> {
97
+ const row = await this.#repo.getById(request.id)
98
+ if (!row) throw ERR_ADMIN_USER_NOT_FOUND()
99
+ return toAdminUser(row)
100
+ }
101
+
102
+ async createUser(request: CreateAdminUserRequest): Promise<AdminUserResponse> {
103
+ // Pre-check for email conflict. The unique index on `email` is the
104
+ // ultimate backstop if a race beats this check; the pre-check exists
105
+ // so the common case returns a clean domain-specific error rather
106
+ // than a raw Postgres code.
107
+ const existing = await this.#repo.getByEmail(request.email)
108
+ if (existing) throw ERR_ADMIN_USER_EMAIL_IN_USE()
109
+
110
+ const password_hash = await hashPassword(request.password)
111
+ const row = await this.#repo.create({
112
+ email: request.email,
113
+ password_hash,
114
+ given_name: request.given_name ?? null,
115
+ family_name: request.family_name ?? null,
116
+ username: request.username ?? null,
117
+ is_super_admin: request.is_super_admin,
118
+ is_enabled: request.is_enabled,
119
+ is_email_verified: request.is_email_verified,
120
+ })
121
+ return toAdminUser(row)
122
+ }
123
+
124
+ async updateUser(request: UpdateAdminUserRequest): Promise<AdminUserResponse> {
125
+ const current = await this.#repo.getById(request.id)
126
+ if (!current) throw ERR_ADMIN_USER_NOT_FOUND()
127
+
128
+ // If email is being changed, check that the new address is not taken
129
+ // by another user.
130
+ if (request.patch.email != null && request.patch.email !== current.email) {
131
+ const owner = await this.#repo.getByEmail(request.patch.email)
132
+ if (owner && owner.id !== request.id) throw ERR_ADMIN_USER_EMAIL_IN_USE()
133
+ }
134
+
135
+ const row = await this.#repo.update(request.id, request.vid, request.patch)
136
+ return toAdminUser(row)
137
+ }
138
+
139
+ async setPassword(request: SetAdminUserPasswordRequest): Promise<AdminUserResponse> {
140
+ const exists = await this.#repo.getById(request.id)
141
+ if (!exists) throw ERR_ADMIN_USER_NOT_FOUND()
142
+ const password_hash = await hashPassword(request.password)
143
+ const row = await this.#repo.setPasswordHash(request.id, request.vid, password_hash)
144
+ return toAdminUser(row)
145
+ }
146
+
147
+ async enableUser(request: EnableAdminUserRequest): Promise<void> {
148
+ const exists = await this.#repo.getById(request.id)
149
+ if (!exists) throw ERR_ADMIN_USER_NOT_FOUND()
150
+ await this.#repo.setEnabled(request.id, true)
151
+ }
152
+
153
+ async disableUser(actor: AdminAuth, request: DisableAdminUserRequest): Promise<void> {
154
+ if (actor.id === request.id) throw ERR_ADMIN_USER_SELF_DISABLE()
155
+ const exists = await this.#repo.getById(request.id)
156
+ if (!exists) throw ERR_ADMIN_USER_NOT_FOUND()
157
+ await this.#repo.setEnabled(request.id, false)
158
+ }
159
+
160
+ async deleteUser(actor: AdminAuth, request: DeleteAdminUserRequest): Promise<void> {
161
+ if (actor.id === request.id) throw ERR_ADMIN_USER_SELF_DELETE()
162
+ const exists = await this.#repo.getById(request.id)
163
+ if (!exists) throw ERR_ADMIN_USER_NOT_FOUND()
164
+ await this.#repo.delete(request.id, request.vid)
165
+ }
166
+ }