@rovela-ai/sdk 0.2.0 → 0.3.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 (178) hide show
  1. package/dist/admin/api/accept-invite.d.ts +65 -0
  2. package/dist/admin/api/accept-invite.d.ts.map +1 -0
  3. package/dist/admin/api/accept-invite.js +115 -0
  4. package/dist/admin/api/accept-invite.js.map +1 -0
  5. package/dist/admin/api/categories.d.ts.map +1 -1
  6. package/dist/admin/api/categories.js +21 -28
  7. package/dist/admin/api/categories.js.map +1 -1
  8. package/dist/admin/api/customers.d.ts.map +1 -1
  9. package/dist/admin/api/customers.js +17 -25
  10. package/dist/admin/api/customers.js.map +1 -1
  11. package/dist/admin/api/forgot-password.d.ts +39 -0
  12. package/dist/admin/api/forgot-password.d.ts.map +1 -0
  13. package/dist/admin/api/forgot-password.js +66 -0
  14. package/dist/admin/api/forgot-password.js.map +1 -0
  15. package/dist/admin/api/index.d.ts +6 -0
  16. package/dist/admin/api/index.d.ts.map +1 -1
  17. package/dist/admin/api/index.js +9 -0
  18. package/dist/admin/api/index.js.map +1 -1
  19. package/dist/admin/api/me.d.ts +72 -0
  20. package/dist/admin/api/me.d.ts.map +1 -0
  21. package/dist/admin/api/me.js +177 -0
  22. package/dist/admin/api/me.js.map +1 -0
  23. package/dist/admin/api/orders.d.ts.map +1 -1
  24. package/dist/admin/api/orders.js +21 -28
  25. package/dist/admin/api/orders.js.map +1 -1
  26. package/dist/admin/api/products.d.ts.map +1 -1
  27. package/dist/admin/api/products.js +33 -37
  28. package/dist/admin/api/products.js.map +1 -1
  29. package/dist/admin/api/refund.d.ts.map +1 -1
  30. package/dist/admin/api/refund.js +5 -7
  31. package/dist/admin/api/refund.js.map +1 -1
  32. package/dist/admin/api/reset-password.d.ts +49 -0
  33. package/dist/admin/api/reset-password.d.ts.map +1 -0
  34. package/dist/admin/api/reset-password.js +99 -0
  35. package/dist/admin/api/reset-password.js.map +1 -0
  36. package/dist/admin/api/return.d.ts.map +1 -1
  37. package/dist/admin/api/return.js +9 -12
  38. package/dist/admin/api/return.js.map +1 -1
  39. package/dist/admin/api/settings.d.ts.map +1 -1
  40. package/dist/admin/api/settings.js +9 -12
  41. package/dist/admin/api/settings.js.map +1 -1
  42. package/dist/admin/api/shipping.d.ts.map +1 -1
  43. package/dist/admin/api/shipping.js +65 -61
  44. package/dist/admin/api/shipping.js.map +1 -1
  45. package/dist/admin/api/stats.d.ts.map +1 -1
  46. package/dist/admin/api/stats.js +5 -7
  47. package/dist/admin/api/stats.js.map +1 -1
  48. package/dist/admin/api/stripe-status.d.ts.map +1 -1
  49. package/dist/admin/api/stripe-status.js +5 -7
  50. package/dist/admin/api/stripe-status.js.map +1 -1
  51. package/dist/admin/api/tax-zones.d.ts.map +1 -1
  52. package/dist/admin/api/tax-zones.js +21 -28
  53. package/dist/admin/api/tax-zones.js.map +1 -1
  54. package/dist/admin/api/users.d.ts +142 -0
  55. package/dist/admin/api/users.d.ts.map +1 -0
  56. package/dist/admin/api/users.js +356 -0
  57. package/dist/admin/api/users.js.map +1 -0
  58. package/dist/admin/components/AdminAcceptInviteForm.d.ts +3 -0
  59. package/dist/admin/components/AdminAcceptInviteForm.d.ts.map +1 -0
  60. package/dist/admin/components/AdminAcceptInviteForm.js +137 -0
  61. package/dist/admin/components/AdminAcceptInviteForm.js.map +1 -0
  62. package/dist/admin/components/AdminAccountPage.d.ts +10 -0
  63. package/dist/admin/components/AdminAccountPage.d.ts.map +1 -0
  64. package/dist/admin/components/AdminAccountPage.js +123 -0
  65. package/dist/admin/components/AdminAccountPage.js.map +1 -0
  66. package/dist/admin/components/AdminForgotPasswordForm.d.ts +8 -0
  67. package/dist/admin/components/AdminForgotPasswordForm.d.ts.map +1 -0
  68. package/dist/admin/components/AdminForgotPasswordForm.js +59 -0
  69. package/dist/admin/components/AdminForgotPasswordForm.js.map +1 -0
  70. package/dist/admin/components/AdminNav.d.ts.map +1 -1
  71. package/dist/admin/components/AdminNav.js +39 -8
  72. package/dist/admin/components/AdminNav.js.map +1 -1
  73. package/dist/admin/components/AdminResetPasswordForm.d.ts +12 -0
  74. package/dist/admin/components/AdminResetPasswordForm.d.ts.map +1 -0
  75. package/dist/admin/components/AdminResetPasswordForm.js +134 -0
  76. package/dist/admin/components/AdminResetPasswordForm.js.map +1 -0
  77. package/dist/admin/components/AdminUserMenu.d.ts.map +1 -1
  78. package/dist/admin/components/AdminUserMenu.js +2 -2
  79. package/dist/admin/components/AdminUserMenu.js.map +1 -1
  80. package/dist/admin/components/InviteUserDialog.d.ts +3 -0
  81. package/dist/admin/components/InviteUserDialog.d.ts.map +1 -0
  82. package/dist/admin/components/InviteUserDialog.js +127 -0
  83. package/dist/admin/components/InviteUserDialog.js.map +1 -0
  84. package/dist/admin/components/UsersTable.d.ts +3 -0
  85. package/dist/admin/components/UsersTable.d.ts.map +1 -0
  86. package/dist/admin/components/UsersTable.js +399 -0
  87. package/dist/admin/components/UsersTable.js.map +1 -0
  88. package/dist/admin/components/index.d.ts +9 -0
  89. package/dist/admin/components/index.d.ts.map +1 -1
  90. package/dist/admin/components/index.js +9 -0
  91. package/dist/admin/components/index.js.map +1 -1
  92. package/dist/admin/config.d.ts.map +1 -1
  93. package/dist/admin/config.js +23 -1
  94. package/dist/admin/config.js.map +1 -1
  95. package/dist/admin/hooks/index.d.ts +4 -0
  96. package/dist/admin/hooks/index.d.ts.map +1 -1
  97. package/dist/admin/hooks/index.js +3 -0
  98. package/dist/admin/hooks/index.js.map +1 -1
  99. package/dist/admin/hooks/useAdminMe.d.ts +31 -0
  100. package/dist/admin/hooks/useAdminMe.d.ts.map +1 -0
  101. package/dist/admin/hooks/useAdminMe.js +103 -0
  102. package/dist/admin/hooks/useAdminMe.js.map +1 -0
  103. package/dist/admin/hooks/useAdminPermissions.d.ts +3 -0
  104. package/dist/admin/hooks/useAdminPermissions.d.ts.map +1 -0
  105. package/dist/admin/hooks/useAdminPermissions.js +51 -0
  106. package/dist/admin/hooks/useAdminPermissions.js.map +1 -0
  107. package/dist/admin/hooks/useAdminUsers.d.ts +3 -0
  108. package/dist/admin/hooks/useAdminUsers.d.ts.map +1 -0
  109. package/dist/admin/hooks/useAdminUsers.js +240 -0
  110. package/dist/admin/hooks/useAdminUsers.js.map +1 -0
  111. package/dist/admin/index.d.ts +4 -4
  112. package/dist/admin/index.d.ts.map +1 -1
  113. package/dist/admin/index.js +20 -2
  114. package/dist/admin/index.js.map +1 -1
  115. package/dist/admin/permissions.d.ts +92 -0
  116. package/dist/admin/permissions.d.ts.map +1 -0
  117. package/dist/admin/permissions.js +201 -0
  118. package/dist/admin/permissions.js.map +1 -0
  119. package/dist/admin/server/admin-invite.d.ts +122 -0
  120. package/dist/admin/server/admin-invite.d.ts.map +1 -0
  121. package/dist/admin/server/admin-invite.js +235 -0
  122. package/dist/admin/server/admin-invite.js.map +1 -0
  123. package/dist/admin/server/admin-password-reset.d.ts +87 -0
  124. package/dist/admin/server/admin-password-reset.d.ts.map +1 -0
  125. package/dist/admin/server/admin-password-reset.js +220 -0
  126. package/dist/admin/server/admin-password-reset.js.map +1 -0
  127. package/dist/admin/server/admin-self-service.d.ts +86 -0
  128. package/dist/admin/server/admin-self-service.d.ts.map +1 -0
  129. package/dist/admin/server/admin-self-service.js +188 -0
  130. package/dist/admin/server/admin-self-service.js.map +1 -0
  131. package/dist/admin/server/admin-service.d.ts.map +1 -1
  132. package/dist/admin/server/admin-service.js +21 -2
  133. package/dist/admin/server/admin-service.js.map +1 -1
  134. package/dist/admin/server/admin-session.d.ts +126 -0
  135. package/dist/admin/server/admin-session.d.ts.map +1 -0
  136. package/dist/admin/server/admin-session.js +215 -0
  137. package/dist/admin/server/admin-session.js.map +1 -0
  138. package/dist/admin/server/index.d.ts +7 -0
  139. package/dist/admin/server/index.d.ts.map +1 -1
  140. package/dist/admin/server/index.js +20 -0
  141. package/dist/admin/server/index.js.map +1 -1
  142. package/dist/admin/server/user-management.d.ts +223 -0
  143. package/dist/admin/server/user-management.d.ts.map +1 -0
  144. package/dist/admin/server/user-management.js +846 -0
  145. package/dist/admin/server/user-management.js.map +1 -0
  146. package/dist/admin/types.d.ts +153 -2
  147. package/dist/admin/types.d.ts.map +1 -1
  148. package/dist/core/db/queries.d.ts +19 -13
  149. package/dist/core/db/queries.d.ts.map +1 -1
  150. package/dist/core/db/schema.d.ts +327 -9
  151. package/dist/core/db/schema.d.ts.map +1 -1
  152. package/dist/core/db/schema.js +80 -3
  153. package/dist/core/db/schema.js.map +1 -1
  154. package/dist/core/types.d.ts +19 -3
  155. package/dist/core/types.d.ts.map +1 -1
  156. package/dist/emails/index.d.ts +2 -2
  157. package/dist/emails/index.d.ts.map +1 -1
  158. package/dist/emails/index.js +3 -1
  159. package/dist/emails/index.js.map +1 -1
  160. package/dist/emails/send/admin-auth.d.ts +94 -0
  161. package/dist/emails/send/admin-auth.d.ts.map +1 -0
  162. package/dist/emails/send/admin-auth.js +118 -0
  163. package/dist/emails/send/admin-auth.js.map +1 -0
  164. package/dist/emails/send/index.d.ts +2 -0
  165. package/dist/emails/send/index.d.ts.map +1 -1
  166. package/dist/emails/send/index.js +4 -0
  167. package/dist/emails/send/index.js.map +1 -1
  168. package/dist/emails/templates/admin-invite.d.ts +40 -0
  169. package/dist/emails/templates/admin-invite.d.ts.map +1 -0
  170. package/dist/emails/templates/admin-invite.js +62 -0
  171. package/dist/emails/templates/admin-invite.js.map +1 -0
  172. package/dist/emails/templates/index.d.ts +1 -0
  173. package/dist/emails/templates/index.d.ts.map +1 -1
  174. package/dist/emails/templates/index.js +4 -0
  175. package/dist/emails/templates/index.js.map +1 -1
  176. package/dist/emails/types.d.ts +22 -1
  177. package/dist/emails/types.d.ts.map +1 -1
  178. package/package.json +21 -1
@@ -0,0 +1,846 @@
1
+ /**
2
+ * @rovela/sdk/admin/server/user-management
3
+ *
4
+ * User lifecycle management for store admins — list, deactivate, reactivate,
5
+ * and hard-delete operations, with all invariants enforced at the service
6
+ * layer so every future caller inherits them automatically.
7
+ *
8
+ * # Invariants (enforced in every mutation)
9
+ *
10
+ * 1. Self-protection: actor cannot act on themselves via these helpers.
11
+ * Self-service flows (password change, profile update) go through
12
+ * dedicated `/api/admin/me/*` endpoints (Phase 4).
13
+ *
14
+ * 2. Last-owner protection: there must ALWAYS be at least one admin with
15
+ * role='owner' AND status='active'. Deactivating, demoting, or deleting
16
+ * the last active owner is rejected. This is enforced via an atomic
17
+ * conditional UPDATE (or a pre-check for hard delete), not a
18
+ * check-then-act pattern — no TOCTOU race window.
19
+ *
20
+ * 3. Lateral-escalation protection: administrators can only touch managers
21
+ * and users. They cannot modify owners or other administrators.
22
+ * Enforced via `canManageUser(actor, target)` from `permissions.ts`.
23
+ *
24
+ * 4. Hard delete requires prior deactivation: you cannot DELETE an admin
25
+ * whose status is 'active' or 'invited'. Must call `deactivateAdmin`
26
+ * first. This creates a two-step safety rail for irreversible actions.
27
+ *
28
+ * 5. Audit trail on deactivate: `deactivated_at` and `deactivated_by` are
29
+ * populated from the service layer. Reactivate clears them.
30
+ *
31
+ * 6. Reactivate only applies to 'deactivated' admins, not 'invited'.
32
+ * Invited admins must accept their invite (Phase 3) to become active.
33
+ *
34
+ * # Session cache invalidation
35
+ *
36
+ * Every mutation ends with `invalidateAdminSession(targetId)` so the 30-second
37
+ * per-admin status cache in `requireAdmin()` sees the new state on the very
38
+ * next request. Without this call, a deactivated admin could continue making
39
+ * authenticated requests for up to 30 seconds.
40
+ *
41
+ * # This module never throws on invariant failures — it returns a typed
42
+ * discriminated union `{ ok: true }` or `{ ok: false, error: {...} }`.
43
+ * Unexpected runtime errors (DB connectivity, etc.) still throw and bubble
44
+ * up to the API route's try/catch.
45
+ */
46
+ import { and, eq, ilike, or, sql, desc } from 'drizzle-orm';
47
+ import { getDb } from '../../core/db/client';
48
+ import * as schema from '../../core/db/schema';
49
+ import { canManageUser, roleLabel } from '../permissions';
50
+ import { findAdminById, findAdminByEmail } from './admin-service';
51
+ import { invalidateAdminSession } from './admin-session';
52
+ import { createInviteToken, deleteAdminInviteTokens, INVITE_EXPIRY_HOURS, } from './admin-invite';
53
+ import { sendAdminInviteEmail } from '../../emails/send/admin-auth';
54
+ import { getStoreUrl } from '../../emails/config';
55
+ // =============================================================================
56
+ // List (read path)
57
+ // =============================================================================
58
+ /**
59
+ * List admins with filters and pagination.
60
+ *
61
+ * Returns the raw `StoreAdmin` rows — the caller is responsible for stripping
62
+ * sensitive fields (`passwordHash`) before serializing to JSON. The API
63
+ * handler does this mapping.
64
+ */
65
+ export async function listAdmins(opts = {}) {
66
+ const db = getDb();
67
+ const limit = Math.max(1, Math.min(100, opts.limit ?? 20));
68
+ const offset = Math.max(0, opts.offset ?? 0);
69
+ // Build WHERE conditions
70
+ const conditions = [];
71
+ if (opts.search && opts.search.trim()) {
72
+ const pattern = `%${opts.search.trim()}%`;
73
+ conditions.push(or(ilike(schema.storeAdmins.email, pattern), ilike(schema.storeAdmins.name, pattern)));
74
+ }
75
+ if (opts.status && opts.status !== 'all') {
76
+ conditions.push(eq(schema.storeAdmins.status, opts.status));
77
+ }
78
+ if (opts.role && opts.role !== 'all') {
79
+ conditions.push(eq(schema.storeAdmins.role, opts.role));
80
+ }
81
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
82
+ // Two parallel queries: page + count
83
+ const [admins, countResult] = await Promise.all([
84
+ db
85
+ .select()
86
+ .from(schema.storeAdmins)
87
+ .where(whereClause)
88
+ // Owners first, then by creation date (newest first)
89
+ .orderBy(sql `CASE ${schema.storeAdmins.role}
90
+ WHEN 'owner' THEN 0
91
+ WHEN 'administrator' THEN 1
92
+ WHEN 'admin' THEN 1
93
+ WHEN 'manager' THEN 2
94
+ WHEN 'user' THEN 3
95
+ ELSE 4
96
+ END`, desc(schema.storeAdmins.createdAt))
97
+ .limit(limit)
98
+ .offset(offset),
99
+ db
100
+ .select({ count: sql `COUNT(*)::int` })
101
+ .from(schema.storeAdmins)
102
+ .where(whereClause),
103
+ ]);
104
+ return {
105
+ admins,
106
+ total: Number(countResult[0]?.count ?? 0),
107
+ };
108
+ }
109
+ // =============================================================================
110
+ // Helper: count active owners
111
+ // =============================================================================
112
+ /**
113
+ * Count admins with role='owner' and status='active'.
114
+ *
115
+ * The single source of truth for the last-owner invariant. Always queried
116
+ * fresh — it's a one-row COUNT, trivially fast, and caching would introduce
117
+ * staleness risk for a safety-critical check.
118
+ */
119
+ export async function countActiveOwners() {
120
+ const db = getDb();
121
+ const [row] = await db
122
+ .select({ count: sql `COUNT(*)::int` })
123
+ .from(schema.storeAdmins)
124
+ .where(and(eq(schema.storeAdmins.role, 'owner'), eq(schema.storeAdmins.status, 'active')));
125
+ return Number(row?.count ?? 0);
126
+ }
127
+ // =============================================================================
128
+ // Deactivate
129
+ // =============================================================================
130
+ /**
131
+ * Soft-delete an admin: set status='deactivated' and stamp audit fields.
132
+ *
133
+ * Rejects on any invariant violation. Never throws on business errors;
134
+ * returns a typed result the caller can branch on.
135
+ *
136
+ * Uses an atomic conditional UPDATE to enforce the last-owner invariant —
137
+ * no check-then-act race window. If another request deactivates the second-
138
+ * to-last owner between our check and our update, our UPDATE's WHERE clause
139
+ * will match zero rows and we return LAST_OWNER_PROTECTED.
140
+ */
141
+ export async function deactivateAdmin(actor, targetId) {
142
+ // Load target
143
+ const target = await findAdminById(targetId);
144
+ if (!target) {
145
+ return {
146
+ ok: false,
147
+ error: { code: 'NOT_FOUND', message: 'Admin not found.' },
148
+ };
149
+ }
150
+ // Self-protection
151
+ if (actor.id === targetId) {
152
+ return {
153
+ ok: false,
154
+ error: {
155
+ code: 'SELF_ACTION_FORBIDDEN',
156
+ message: 'You cannot deactivate your own account.',
157
+ },
158
+ };
159
+ }
160
+ // Lateral-escalation protection
161
+ if (!canManageUser(actor, { id: target.id, role: target.role })) {
162
+ return {
163
+ ok: false,
164
+ error: {
165
+ code: 'FORBIDDEN',
166
+ message: 'You do not have permission to deactivate this admin.',
167
+ },
168
+ };
169
+ }
170
+ // Must currently be active (nothing to deactivate otherwise)
171
+ if (target.status !== 'active') {
172
+ return {
173
+ ok: false,
174
+ error: {
175
+ code: 'INVALID_STATE',
176
+ message: `Admin is already ${target.status}.`,
177
+ },
178
+ };
179
+ }
180
+ const db = getDb();
181
+ // Atomic update with embedded last-owner guard. If the target is an owner
182
+ // and they're the last active one, the subquery `> 1` evaluates to false
183
+ // and the UPDATE matches zero rows.
184
+ //
185
+ // For non-owner targets, the subquery check is short-circuited because the
186
+ // role condition in the WHERE clause excludes them from the owner-counting
187
+ // branch. We use two separate UPDATEs keyed on the target's role to keep
188
+ // the SQL readable.
189
+ if (target.role === 'owner') {
190
+ // Guarded: only succeeds if there's at least one OTHER active owner.
191
+ const result = await db
192
+ .update(schema.storeAdmins)
193
+ .set({
194
+ status: 'deactivated',
195
+ deactivatedAt: new Date(),
196
+ deactivatedBy: actor.id,
197
+ })
198
+ .where(and(eq(schema.storeAdmins.id, targetId), eq(schema.storeAdmins.status, 'active'), sql `(SELECT COUNT(*)::int FROM ${schema.storeAdmins} WHERE role = 'owner' AND status = 'active') > 1`))
199
+ .returning({ id: schema.storeAdmins.id });
200
+ if (result.length === 0) {
201
+ return {
202
+ ok: false,
203
+ error: {
204
+ code: 'LAST_OWNER_PROTECTED',
205
+ message: 'Cannot deactivate the last active owner. Transfer ownership or promote another user first.',
206
+ },
207
+ };
208
+ }
209
+ }
210
+ else {
211
+ // Non-owner: straightforward update, still guarded on current status
212
+ // so concurrent requests don't double-apply.
213
+ const result = await db
214
+ .update(schema.storeAdmins)
215
+ .set({
216
+ status: 'deactivated',
217
+ deactivatedAt: new Date(),
218
+ deactivatedBy: actor.id,
219
+ })
220
+ .where(and(eq(schema.storeAdmins.id, targetId), eq(schema.storeAdmins.status, 'active')))
221
+ .returning({ id: schema.storeAdmins.id });
222
+ if (result.length === 0) {
223
+ // Raced with another request — target was modified mid-flight
224
+ return {
225
+ ok: false,
226
+ error: {
227
+ code: 'INVALID_STATE',
228
+ message: 'Admin state changed concurrently. Please refresh and try again.',
229
+ },
230
+ };
231
+ }
232
+ }
233
+ // Force the target's next request to re-read from DB → 401 kick-out
234
+ invalidateAdminSession(targetId);
235
+ return { ok: true };
236
+ }
237
+ // =============================================================================
238
+ // Reactivate
239
+ // =============================================================================
240
+ /**
241
+ * Reactivate a previously-deactivated admin: set status='active', clear
242
+ * audit fields. Does NOT apply to 'invited' admins — they must accept
243
+ * their invite (Phase 3) to become active.
244
+ */
245
+ export async function reactivateAdmin(actor, targetId) {
246
+ const target = await findAdminById(targetId);
247
+ if (!target) {
248
+ return {
249
+ ok: false,
250
+ error: { code: 'NOT_FOUND', message: 'Admin not found.' },
251
+ };
252
+ }
253
+ // Self-protection — a deactivated admin can't hit this endpoint anyway
254
+ // (they can't sign in), but defense-in-depth is cheap.
255
+ if (actor.id === targetId) {
256
+ return {
257
+ ok: false,
258
+ error: {
259
+ code: 'SELF_ACTION_FORBIDDEN',
260
+ message: 'You cannot modify your own account here.',
261
+ },
262
+ };
263
+ }
264
+ if (!canManageUser(actor, { id: target.id, role: target.role })) {
265
+ return {
266
+ ok: false,
267
+ error: {
268
+ code: 'FORBIDDEN',
269
+ message: 'You do not have permission to reactivate this admin.',
270
+ },
271
+ };
272
+ }
273
+ if (target.status !== 'deactivated') {
274
+ return {
275
+ ok: false,
276
+ error: {
277
+ code: 'INVALID_STATE',
278
+ message: `Only deactivated admins can be reactivated. This admin is ${target.status}.`,
279
+ },
280
+ };
281
+ }
282
+ const db = getDb();
283
+ const result = await db
284
+ .update(schema.storeAdmins)
285
+ .set({
286
+ status: 'active',
287
+ deactivatedAt: null,
288
+ deactivatedBy: null,
289
+ })
290
+ .where(and(eq(schema.storeAdmins.id, targetId), eq(schema.storeAdmins.status, 'deactivated')))
291
+ .returning({ id: schema.storeAdmins.id });
292
+ if (result.length === 0) {
293
+ return {
294
+ ok: false,
295
+ error: {
296
+ code: 'INVALID_STATE',
297
+ message: 'Admin state changed concurrently. Please refresh and try again.',
298
+ },
299
+ };
300
+ }
301
+ invalidateAdminSession(targetId);
302
+ return { ok: true };
303
+ }
304
+ // =============================================================================
305
+ // Hard delete (owner-only, requires prior deactivation)
306
+ // =============================================================================
307
+ /**
308
+ * Permanently delete an admin row. Cascades to `admin_password_reset_tokens`
309
+ * and `admin_invite_tokens` via the `ON DELETE CASCADE` foreign keys defined
310
+ * in the Phase 0 schema.
311
+ *
312
+ * Requires the target to already be 'deactivated' — this is a two-step
313
+ * irreversible action. The API route additionally gates on `users.delete`
314
+ * permission (owner-only), but we re-check here for defense-in-depth.
315
+ *
316
+ * Dangling `deactivated_by` / `created_by` references on other admin rows
317
+ * are intentionally left as dangling UUIDs. The UI renders them as
318
+ * "Unknown user". Phase 4 may revisit.
319
+ */
320
+ export async function hardDeleteAdmin(actor, targetId) {
321
+ const target = await findAdminById(targetId);
322
+ if (!target) {
323
+ return {
324
+ ok: false,
325
+ error: { code: 'NOT_FOUND', message: 'Admin not found.' },
326
+ };
327
+ }
328
+ if (actor.id === targetId) {
329
+ return {
330
+ ok: false,
331
+ error: {
332
+ code: 'SELF_ACTION_FORBIDDEN',
333
+ message: 'You cannot delete your own account.',
334
+ },
335
+ };
336
+ }
337
+ // Defense-in-depth: only owners reach this code path because the API
338
+ // handler gates on `users.delete` permission, but we re-check here so
339
+ // the invariant holds even if a non-owner caller bypasses the HTTP layer.
340
+ if (actor.role !== 'owner') {
341
+ return {
342
+ ok: false,
343
+ error: {
344
+ code: 'FORBIDDEN',
345
+ message: 'Only owners can permanently delete admins.',
346
+ },
347
+ };
348
+ }
349
+ if (!canManageUser(actor, { id: target.id, role: target.role })) {
350
+ return {
351
+ ok: false,
352
+ error: {
353
+ code: 'FORBIDDEN',
354
+ message: 'You do not have permission to delete this admin.',
355
+ },
356
+ };
357
+ }
358
+ // Must already be deactivated — the two-step safety rail for irreversible
359
+ // destruction. The UI never offers "Delete" on an active row.
360
+ if (target.status !== 'deactivated') {
361
+ return {
362
+ ok: false,
363
+ error: {
364
+ code: 'MUST_DEACTIVATE_FIRST',
365
+ message: 'You must deactivate this admin before permanently deleting them.',
366
+ },
367
+ };
368
+ }
369
+ // Deactivated owners don't count toward the active-owner total, so the
370
+ // last-owner invariant cannot be violated by this delete — but we still
371
+ // short-circuit with a defensive check in case the schema grows in the
372
+ // future and the 'deactivated' state becomes ambiguous.
373
+ if (target.role === 'owner') {
374
+ const activeOwners = await countActiveOwners();
375
+ if (activeOwners < 1) {
376
+ return {
377
+ ok: false,
378
+ error: {
379
+ code: 'LAST_OWNER_PROTECTED',
380
+ message: 'Cannot delete the last owner — at least one active owner must exist.',
381
+ },
382
+ };
383
+ }
384
+ }
385
+ const db = getDb();
386
+ const result = await db
387
+ .delete(schema.storeAdmins)
388
+ .where(and(eq(schema.storeAdmins.id, targetId), eq(schema.storeAdmins.status, 'deactivated')))
389
+ .returning({ id: schema.storeAdmins.id });
390
+ if (result.length === 0) {
391
+ return {
392
+ ok: false,
393
+ error: {
394
+ code: 'INVALID_STATE',
395
+ message: 'Admin state changed concurrently. Please refresh and try again.',
396
+ },
397
+ };
398
+ }
399
+ invalidateAdminSession(targetId);
400
+ return { ok: true };
401
+ }
402
+ // =============================================================================
403
+ // Helper: HTTP status code mapping for errors
404
+ // =============================================================================
405
+ /**
406
+ * Map a `UserManagementError.code` to the HTTP status the API handler
407
+ * should respond with. Exported so both the API handlers and any future
408
+ * caller use the same mapping.
409
+ */
410
+ export function statusCodeFor(code) {
411
+ switch (code) {
412
+ case 'NOT_FOUND':
413
+ return 404;
414
+ case 'FORBIDDEN':
415
+ case 'SELF_ACTION_FORBIDDEN':
416
+ return 403;
417
+ case 'VALIDATION_ERROR':
418
+ return 400;
419
+ case 'LAST_OWNER_PROTECTED':
420
+ case 'MUST_DEACTIVATE_FIRST':
421
+ case 'INVALID_STATE':
422
+ case 'EMAIL_ALREADY_EXISTS':
423
+ case 'EMAIL_ALREADY_INVITED':
424
+ case 'EMAIL_DEACTIVATED_EXISTS':
425
+ return 409;
426
+ default:
427
+ // Exhaustiveness safeguard — if the union grows without this
428
+ // function being updated, TS will complain here at compile time.
429
+ return 500;
430
+ }
431
+ }
432
+ /**
433
+ * Invite a new admin to manage the store.
434
+ *
435
+ * Creates a `store_admins` row in `invited` status with no password,
436
+ * issues an invite token (72h expiry), and sends the invite email. The
437
+ * caller receives both the token AND the fully-qualified invite URL — so
438
+ * if email delivery fails, the UI can show a "copy link manually"
439
+ * fallback instead of silently losing the invite.
440
+ *
441
+ * # Invariants (in order)
442
+ *
443
+ * 1. `canManageUser(actor, {role})` — actor can grant the requested role.
444
+ * Administrators cannot invite owners or other administrators.
445
+ * 2. Valid email format + name length ≥ 2.
446
+ * 3. Email uniqueness — rejects with a distinct error code depending on
447
+ * the existing row's status (active / invited / deactivated) so the
448
+ * UI can show actionable messages.
449
+ * 4. Legal role: must be one of 'owner' | 'administrator' | 'manager' | 'user'.
450
+ * The legacy 'admin' value is never chosen by the caller here — new
451
+ * invites always use the canonical names.
452
+ *
453
+ * Email send failures are logged but NOT propagated — the invite row +
454
+ * token are already persisted, the caller gets the URL, and the UI
455
+ * handles fallback display.
456
+ */
457
+ export async function inviteAdmin(actor, request) {
458
+ const email = (request.email || '').trim().toLowerCase();
459
+ const name = (request.name || '').trim();
460
+ const role = request.role;
461
+ // Basic format validation
462
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
463
+ return {
464
+ ok: false,
465
+ error: {
466
+ code: 'VALIDATION_ERROR',
467
+ message: 'Please enter a valid email address.',
468
+ },
469
+ };
470
+ }
471
+ if (name.length < 2) {
472
+ return {
473
+ ok: false,
474
+ error: {
475
+ code: 'VALIDATION_ERROR',
476
+ message: 'Name must be at least 2 characters.',
477
+ },
478
+ };
479
+ }
480
+ if (role !== 'owner' &&
481
+ role !== 'administrator' &&
482
+ role !== 'manager' &&
483
+ role !== 'user') {
484
+ return {
485
+ ok: false,
486
+ error: {
487
+ code: 'VALIDATION_ERROR',
488
+ message: 'Invalid role. Must be owner, administrator, manager, or user.',
489
+ },
490
+ };
491
+ }
492
+ // Permission: actor must be allowed to grant this role. We pass a
493
+ // synthetic target with a fake id so `canManageUser` is just checking
494
+ // the role-vs-role hierarchy (it does an actor.id === target.id check
495
+ // for self-protection, which is irrelevant here — no self-invite).
496
+ if (!canManageUser(actor, { id: '__new_invitee__', role })) {
497
+ return {
498
+ ok: false,
499
+ error: {
500
+ code: 'FORBIDDEN',
501
+ message: `You do not have permission to invite someone as ${roleLabel(role)}.`,
502
+ },
503
+ };
504
+ }
505
+ // Email uniqueness
506
+ const existing = await findAdminByEmail(email);
507
+ if (existing) {
508
+ const existingStatus = existing.status ?? 'active';
509
+ if (existingStatus === 'active') {
510
+ return {
511
+ ok: false,
512
+ error: {
513
+ code: 'EMAIL_ALREADY_EXISTS',
514
+ message: 'An active admin with this email already exists.',
515
+ },
516
+ };
517
+ }
518
+ if (existingStatus === 'invited') {
519
+ return {
520
+ ok: false,
521
+ error: {
522
+ code: 'EMAIL_ALREADY_INVITED',
523
+ message: 'This email was already invited. Use the Resend invite action instead.',
524
+ },
525
+ };
526
+ }
527
+ return {
528
+ ok: false,
529
+ error: {
530
+ code: 'EMAIL_DEACTIVATED_EXISTS',
531
+ message: 'A previously deactivated admin exists with this email. Reactivate them instead of re-inviting.',
532
+ },
533
+ };
534
+ }
535
+ // Insert the invited admin row. passwordHash stays null until the
536
+ // invitee accepts the invite and sets their own.
537
+ const db = getDb();
538
+ const [created] = await db
539
+ .insert(schema.storeAdmins)
540
+ .values({
541
+ email,
542
+ name,
543
+ passwordHash: null,
544
+ role,
545
+ status: 'invited',
546
+ createdBy: actor.id,
547
+ })
548
+ .returning({ id: schema.storeAdmins.id });
549
+ if (!created) {
550
+ return {
551
+ ok: false,
552
+ error: {
553
+ code: 'INVALID_STATE',
554
+ message: 'Failed to create invited admin. Please try again.',
555
+ },
556
+ };
557
+ }
558
+ // Create the invite token (72h)
559
+ const { token } = await createInviteToken({
560
+ adminId: created.id,
561
+ invitedBy: actor.id,
562
+ });
563
+ // Build the invite URL. `getStoreUrl` reads from NEXT_PUBLIC_APP_URL /
564
+ // NEXTAUTH_URL and returns '' if neither is set — the UI still gets a
565
+ // valid (if short) URL it can show.
566
+ const storeUrl = getStoreUrl();
567
+ const inviteUrl = `${storeUrl}/admin/accept-invite?token=${encodeURIComponent(token)}`;
568
+ // Best-effort send. We never fail the invite just because the email
569
+ // broker is down — the owner sees the URL in the response and can
570
+ // share it manually.
571
+ try {
572
+ // Resolve the inviter's display name from the `store_admins` row so
573
+ // we can render it in the email greeting.
574
+ const inviter = await findAdminById(actor.id);
575
+ const inviterName = inviter?.name || 'An admin';
576
+ await sendAdminInviteEmail({
577
+ to: email,
578
+ adminName: name,
579
+ inviterName,
580
+ roleLabel: roleLabel(role),
581
+ acceptLink: inviteUrl,
582
+ expiryHours: INVITE_EXPIRY_HOURS,
583
+ });
584
+ }
585
+ catch (err) {
586
+ console.error('[user-management] Failed to send invite email:', err instanceof Error ? err.message : err);
587
+ // Intentionally swallow — the caller already has the URL.
588
+ }
589
+ return {
590
+ ok: true,
591
+ adminId: created.id,
592
+ token,
593
+ inviteUrl,
594
+ };
595
+ }
596
+ /**
597
+ * Resend an invite to an admin who is still in `invited` status.
598
+ *
599
+ * Generates a fresh token (invalidating any previous ones) and resends
600
+ * the email. Preserves the single-active-token policy — older tokens
601
+ * are deleted before the new one is created.
602
+ */
603
+ export async function resendAdminInvite(actor, targetId) {
604
+ const target = await findAdminById(targetId);
605
+ if (!target) {
606
+ return {
607
+ ok: false,
608
+ error: { code: 'NOT_FOUND', message: 'Admin not found.' },
609
+ };
610
+ }
611
+ if (!canManageUser(actor, { id: target.id, role: target.role })) {
612
+ return {
613
+ ok: false,
614
+ error: {
615
+ code: 'FORBIDDEN',
616
+ message: 'You do not have permission to resend this invite.',
617
+ },
618
+ };
619
+ }
620
+ if (target.status !== 'invited') {
621
+ return {
622
+ ok: false,
623
+ error: {
624
+ code: 'INVALID_STATE',
625
+ message: target.status === 'active'
626
+ ? 'This admin has already accepted their invite.'
627
+ : 'This admin cannot be re-invited.',
628
+ },
629
+ };
630
+ }
631
+ // Clear previous tokens so only the new one is valid
632
+ await deleteAdminInviteTokens(target.id);
633
+ const { token } = await createInviteToken({
634
+ adminId: target.id,
635
+ invitedBy: actor.id,
636
+ });
637
+ const storeUrl = getStoreUrl();
638
+ const inviteUrl = `${storeUrl}/admin/accept-invite?token=${encodeURIComponent(token)}`;
639
+ try {
640
+ const inviter = await findAdminById(actor.id);
641
+ const inviterName = inviter?.name || 'An admin';
642
+ await sendAdminInviteEmail({
643
+ to: target.email,
644
+ adminName: target.name,
645
+ inviterName,
646
+ roleLabel: roleLabel(target.role),
647
+ acceptLink: inviteUrl,
648
+ expiryHours: INVITE_EXPIRY_HOURS,
649
+ });
650
+ }
651
+ catch (err) {
652
+ console.error('[user-management] Failed to resend invite email:', err instanceof Error ? err.message : err);
653
+ }
654
+ return { ok: true, token, inviteUrl };
655
+ }
656
+ // =============================================================================
657
+ // Phase 3 — Cancel invite
658
+ // =============================================================================
659
+ /**
660
+ * Cancel a pending invite. Hard-deletes the `store_admins` row — which
661
+ * cascades via FK to delete all `admin_invite_tokens` rows for that
662
+ * admin. Only valid for `invited` status (not `active` / `deactivated`).
663
+ *
664
+ * Uses `users.write` permission (administrators + owners), unlike the
665
+ * DELETE endpoint which is owner-only. Canceling an invite for a manager
666
+ * or user is a normal administrator action, not a destructive one.
667
+ */
668
+ export async function cancelAdminInvite(actor, targetId) {
669
+ const target = await findAdminById(targetId);
670
+ if (!target) {
671
+ return {
672
+ ok: false,
673
+ error: { code: 'NOT_FOUND', message: 'Admin not found.' },
674
+ };
675
+ }
676
+ // Defense-in-depth: self-action should be impossible (you can't invite
677
+ // yourself), but check anyway.
678
+ if (actor.id === targetId) {
679
+ return {
680
+ ok: false,
681
+ error: {
682
+ code: 'SELF_ACTION_FORBIDDEN',
683
+ message: 'You cannot cancel your own invite.',
684
+ },
685
+ };
686
+ }
687
+ if (!canManageUser(actor, { id: target.id, role: target.role })) {
688
+ return {
689
+ ok: false,
690
+ error: {
691
+ code: 'FORBIDDEN',
692
+ message: 'You do not have permission to cancel this invite.',
693
+ },
694
+ };
695
+ }
696
+ if (target.status !== 'invited') {
697
+ return {
698
+ ok: false,
699
+ error: {
700
+ code: 'INVALID_STATE',
701
+ message: 'Only pending invites can be cancelled.',
702
+ },
703
+ };
704
+ }
705
+ const db = getDb();
706
+ const result = await db
707
+ .delete(schema.storeAdmins)
708
+ .where(and(eq(schema.storeAdmins.id, targetId), eq(schema.storeAdmins.status, 'invited')))
709
+ .returning({ id: schema.storeAdmins.id });
710
+ if (result.length === 0) {
711
+ return {
712
+ ok: false,
713
+ error: {
714
+ code: 'INVALID_STATE',
715
+ message: 'Admin state changed concurrently. Please refresh and try again.',
716
+ },
717
+ };
718
+ }
719
+ invalidateAdminSession(targetId);
720
+ return { ok: true };
721
+ }
722
+ // =============================================================================
723
+ // Phase 3 — Change role
724
+ // =============================================================================
725
+ /**
726
+ * Change an admin's role.
727
+ *
728
+ * Invariants (in strict order):
729
+ *
730
+ * 1. Target exists (→ NOT_FOUND).
731
+ * 2. Self-protection — actor cannot change their own role (→
732
+ * SELF_ACTION_FORBIDDEN). A solo owner needing to demote themselves
733
+ * must either invite another owner first, or use a future Phase 4
734
+ * emergency-reset flow.
735
+ * 3. canManageUser at CURRENT role — actor must be allowed to manage
736
+ * the target at their current role (administrator cannot touch
737
+ * owners or other administrators).
738
+ * 4. canManageUser at NEW role — actor must be allowed to grant the
739
+ * new role (administrator cannot promote to administrator/owner).
740
+ * 5. No-op fast path — if newRole === current role, return success
741
+ * without touching the DB.
742
+ * 6. Last-owner protection — if target is currently 'owner' and the
743
+ * new role is not 'owner', require `countActiveOwners() > 1`.
744
+ * Enforced atomically via a conditional UPDATE so there's no
745
+ * check-then-act race window.
746
+ * 7. Update; invalidate session cache.
747
+ */
748
+ export async function changeAdminRole(actor, targetId, newRole) {
749
+ const target = await findAdminById(targetId);
750
+ if (!target) {
751
+ return {
752
+ ok: false,
753
+ error: { code: 'NOT_FOUND', message: 'Admin not found.' },
754
+ };
755
+ }
756
+ if (actor.id === targetId) {
757
+ return {
758
+ ok: false,
759
+ error: {
760
+ code: 'SELF_ACTION_FORBIDDEN',
761
+ message: 'You cannot change your own role. Ask another owner to make this change.',
762
+ },
763
+ };
764
+ }
765
+ // Validate role value
766
+ if (newRole !== 'owner' &&
767
+ newRole !== 'administrator' &&
768
+ newRole !== 'manager' &&
769
+ newRole !== 'user') {
770
+ return {
771
+ ok: false,
772
+ error: {
773
+ code: 'VALIDATION_ERROR',
774
+ message: 'Invalid role.',
775
+ },
776
+ };
777
+ }
778
+ // canManageUser at CURRENT role
779
+ if (!canManageUser(actor, { id: target.id, role: target.role })) {
780
+ return {
781
+ ok: false,
782
+ error: {
783
+ code: 'FORBIDDEN',
784
+ message: 'You do not have permission to manage this admin.',
785
+ },
786
+ };
787
+ }
788
+ // canManageUser at NEW role (pass a synthetic target id so the
789
+ // self-check is neutral; we already ruled out self above)
790
+ if (!canManageUser(actor, { id: '__new_role_target__', role: newRole })) {
791
+ return {
792
+ ok: false,
793
+ error: {
794
+ code: 'FORBIDDEN',
795
+ message: `You do not have permission to grant the ${roleLabel(newRole)} role.`,
796
+ },
797
+ };
798
+ }
799
+ // No-op fast path. Note: the legacy 'admin' value is equivalent to
800
+ // 'administrator' in our permission model, so treat them as identical
801
+ // here to avoid rewriting old rows to the new name. The validation
802
+ // block above narrows `newRole` to the four canonical values, so we
803
+ // only need to normalize the TARGET's role (which may still be 'admin').
804
+ const currentCanonical = target.role === 'admin' ? 'administrator' : target.role;
805
+ if (currentCanonical === newRole) {
806
+ return { ok: true };
807
+ }
808
+ const db = getDb();
809
+ if (target.role === 'owner' && newRole !== 'owner') {
810
+ // Demotion from owner — guarded with the last-owner invariant
811
+ const result = await db
812
+ .update(schema.storeAdmins)
813
+ .set({ role: newRole })
814
+ .where(and(eq(schema.storeAdmins.id, targetId), eq(schema.storeAdmins.role, 'owner'), sql `(SELECT COUNT(*)::int FROM ${schema.storeAdmins} WHERE role = 'owner' AND status = 'active') > 1`))
815
+ .returning({ id: schema.storeAdmins.id });
816
+ if (result.length === 0) {
817
+ return {
818
+ ok: false,
819
+ error: {
820
+ code: 'LAST_OWNER_PROTECTED',
821
+ message: 'Cannot demote the last active owner. Promote another admin to owner first.',
822
+ },
823
+ };
824
+ }
825
+ }
826
+ else {
827
+ // Non-owner-demotion path: straightforward update
828
+ const result = await db
829
+ .update(schema.storeAdmins)
830
+ .set({ role: newRole })
831
+ .where(eq(schema.storeAdmins.id, targetId))
832
+ .returning({ id: schema.storeAdmins.id });
833
+ if (result.length === 0) {
834
+ return {
835
+ ok: false,
836
+ error: {
837
+ code: 'INVALID_STATE',
838
+ message: 'Admin state changed concurrently. Please refresh and try again.',
839
+ },
840
+ };
841
+ }
842
+ }
843
+ invalidateAdminSession(targetId);
844
+ return { ok: true };
845
+ }
846
+ //# sourceMappingURL=user-management.js.map