@rovela-ai/sdk 0.2.1 → 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.
- package/dist/admin/api/accept-invite.d.ts +65 -0
- package/dist/admin/api/accept-invite.d.ts.map +1 -0
- package/dist/admin/api/accept-invite.js +115 -0
- package/dist/admin/api/accept-invite.js.map +1 -0
- package/dist/admin/api/categories.d.ts.map +1 -1
- package/dist/admin/api/categories.js +21 -28
- package/dist/admin/api/categories.js.map +1 -1
- package/dist/admin/api/customers.d.ts.map +1 -1
- package/dist/admin/api/customers.js +17 -25
- package/dist/admin/api/customers.js.map +1 -1
- package/dist/admin/api/forgot-password.d.ts +39 -0
- package/dist/admin/api/forgot-password.d.ts.map +1 -0
- package/dist/admin/api/forgot-password.js +66 -0
- package/dist/admin/api/forgot-password.js.map +1 -0
- package/dist/admin/api/index.d.ts +6 -0
- package/dist/admin/api/index.d.ts.map +1 -1
- package/dist/admin/api/index.js +9 -0
- package/dist/admin/api/index.js.map +1 -1
- package/dist/admin/api/me.d.ts +72 -0
- package/dist/admin/api/me.d.ts.map +1 -0
- package/dist/admin/api/me.js +177 -0
- package/dist/admin/api/me.js.map +1 -0
- package/dist/admin/api/orders.d.ts.map +1 -1
- package/dist/admin/api/orders.js +21 -28
- package/dist/admin/api/orders.js.map +1 -1
- package/dist/admin/api/products.d.ts.map +1 -1
- package/dist/admin/api/products.js +33 -37
- package/dist/admin/api/products.js.map +1 -1
- package/dist/admin/api/refund.d.ts.map +1 -1
- package/dist/admin/api/refund.js +5 -7
- package/dist/admin/api/refund.js.map +1 -1
- package/dist/admin/api/reset-password.d.ts +49 -0
- package/dist/admin/api/reset-password.d.ts.map +1 -0
- package/dist/admin/api/reset-password.js +99 -0
- package/dist/admin/api/reset-password.js.map +1 -0
- package/dist/admin/api/return.d.ts.map +1 -1
- package/dist/admin/api/return.js +9 -12
- package/dist/admin/api/return.js.map +1 -1
- package/dist/admin/api/settings.d.ts.map +1 -1
- package/dist/admin/api/settings.js +9 -12
- package/dist/admin/api/settings.js.map +1 -1
- package/dist/admin/api/shipping.d.ts.map +1 -1
- package/dist/admin/api/shipping.js +65 -61
- package/dist/admin/api/shipping.js.map +1 -1
- package/dist/admin/api/stats.d.ts.map +1 -1
- package/dist/admin/api/stats.js +5 -7
- package/dist/admin/api/stats.js.map +1 -1
- package/dist/admin/api/stripe-status.d.ts.map +1 -1
- package/dist/admin/api/stripe-status.js +5 -7
- package/dist/admin/api/stripe-status.js.map +1 -1
- package/dist/admin/api/tax-zones.d.ts.map +1 -1
- package/dist/admin/api/tax-zones.js +21 -28
- package/dist/admin/api/tax-zones.js.map +1 -1
- package/dist/admin/api/users.d.ts +142 -0
- package/dist/admin/api/users.d.ts.map +1 -0
- package/dist/admin/api/users.js +356 -0
- package/dist/admin/api/users.js.map +1 -0
- package/dist/admin/components/AdminAcceptInviteForm.d.ts +3 -0
- package/dist/admin/components/AdminAcceptInviteForm.d.ts.map +1 -0
- package/dist/admin/components/AdminAcceptInviteForm.js +137 -0
- package/dist/admin/components/AdminAcceptInviteForm.js.map +1 -0
- package/dist/admin/components/AdminAccountPage.d.ts +10 -0
- package/dist/admin/components/AdminAccountPage.d.ts.map +1 -0
- package/dist/admin/components/AdminAccountPage.js +123 -0
- package/dist/admin/components/AdminAccountPage.js.map +1 -0
- package/dist/admin/components/AdminForgotPasswordForm.d.ts +8 -0
- package/dist/admin/components/AdminForgotPasswordForm.d.ts.map +1 -0
- package/dist/admin/components/AdminForgotPasswordForm.js +59 -0
- package/dist/admin/components/AdminForgotPasswordForm.js.map +1 -0
- package/dist/admin/components/AdminNav.d.ts.map +1 -1
- package/dist/admin/components/AdminNav.js +32 -4
- package/dist/admin/components/AdminNav.js.map +1 -1
- package/dist/admin/components/AdminResetPasswordForm.d.ts +12 -0
- package/dist/admin/components/AdminResetPasswordForm.d.ts.map +1 -0
- package/dist/admin/components/AdminResetPasswordForm.js +134 -0
- package/dist/admin/components/AdminResetPasswordForm.js.map +1 -0
- package/dist/admin/components/AdminUserMenu.d.ts.map +1 -1
- package/dist/admin/components/AdminUserMenu.js +2 -2
- package/dist/admin/components/AdminUserMenu.js.map +1 -1
- package/dist/admin/components/InviteUserDialog.d.ts +3 -0
- package/dist/admin/components/InviteUserDialog.d.ts.map +1 -0
- package/dist/admin/components/InviteUserDialog.js +127 -0
- package/dist/admin/components/InviteUserDialog.js.map +1 -0
- package/dist/admin/components/UsersTable.d.ts +3 -0
- package/dist/admin/components/UsersTable.d.ts.map +1 -0
- package/dist/admin/components/UsersTable.js +399 -0
- package/dist/admin/components/UsersTable.js.map +1 -0
- package/dist/admin/components/index.d.ts +9 -0
- package/dist/admin/components/index.d.ts.map +1 -1
- package/dist/admin/components/index.js +9 -0
- package/dist/admin/components/index.js.map +1 -1
- package/dist/admin/config.d.ts.map +1 -1
- package/dist/admin/config.js +23 -1
- package/dist/admin/config.js.map +1 -1
- package/dist/admin/hooks/index.d.ts +4 -0
- package/dist/admin/hooks/index.d.ts.map +1 -1
- package/dist/admin/hooks/index.js +3 -0
- package/dist/admin/hooks/index.js.map +1 -1
- package/dist/admin/hooks/useAdminMe.d.ts +31 -0
- package/dist/admin/hooks/useAdminMe.d.ts.map +1 -0
- package/dist/admin/hooks/useAdminMe.js +103 -0
- package/dist/admin/hooks/useAdminMe.js.map +1 -0
- package/dist/admin/hooks/useAdminPermissions.d.ts +3 -0
- package/dist/admin/hooks/useAdminPermissions.d.ts.map +1 -0
- package/dist/admin/hooks/useAdminPermissions.js +51 -0
- package/dist/admin/hooks/useAdminPermissions.js.map +1 -0
- package/dist/admin/hooks/useAdminUsers.d.ts +3 -0
- package/dist/admin/hooks/useAdminUsers.d.ts.map +1 -0
- package/dist/admin/hooks/useAdminUsers.js +240 -0
- package/dist/admin/hooks/useAdminUsers.js.map +1 -0
- package/dist/admin/index.d.ts +4 -4
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +20 -2
- package/dist/admin/index.js.map +1 -1
- package/dist/admin/permissions.d.ts +92 -0
- package/dist/admin/permissions.d.ts.map +1 -0
- package/dist/admin/permissions.js +201 -0
- package/dist/admin/permissions.js.map +1 -0
- package/dist/admin/server/admin-invite.d.ts +122 -0
- package/dist/admin/server/admin-invite.d.ts.map +1 -0
- package/dist/admin/server/admin-invite.js +235 -0
- package/dist/admin/server/admin-invite.js.map +1 -0
- package/dist/admin/server/admin-password-reset.d.ts +87 -0
- package/dist/admin/server/admin-password-reset.d.ts.map +1 -0
- package/dist/admin/server/admin-password-reset.js +220 -0
- package/dist/admin/server/admin-password-reset.js.map +1 -0
- package/dist/admin/server/admin-self-service.d.ts +86 -0
- package/dist/admin/server/admin-self-service.d.ts.map +1 -0
- package/dist/admin/server/admin-self-service.js +188 -0
- package/dist/admin/server/admin-self-service.js.map +1 -0
- package/dist/admin/server/admin-service.d.ts.map +1 -1
- package/dist/admin/server/admin-service.js +21 -2
- package/dist/admin/server/admin-service.js.map +1 -1
- package/dist/admin/server/admin-session.d.ts +126 -0
- package/dist/admin/server/admin-session.d.ts.map +1 -0
- package/dist/admin/server/admin-session.js +215 -0
- package/dist/admin/server/admin-session.js.map +1 -0
- package/dist/admin/server/index.d.ts +7 -0
- package/dist/admin/server/index.d.ts.map +1 -1
- package/dist/admin/server/index.js +20 -0
- package/dist/admin/server/index.js.map +1 -1
- package/dist/admin/server/user-management.d.ts +223 -0
- package/dist/admin/server/user-management.d.ts.map +1 -0
- package/dist/admin/server/user-management.js +846 -0
- package/dist/admin/server/user-management.js.map +1 -0
- package/dist/admin/types.d.ts +153 -2
- package/dist/admin/types.d.ts.map +1 -1
- package/dist/core/db/queries.d.ts +19 -13
- package/dist/core/db/queries.d.ts.map +1 -1
- package/dist/core/db/schema.d.ts +327 -9
- package/dist/core/db/schema.d.ts.map +1 -1
- package/dist/core/db/schema.js +80 -3
- package/dist/core/db/schema.js.map +1 -1
- package/dist/core/types.d.ts +19 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/emails/index.d.ts +2 -2
- package/dist/emails/index.d.ts.map +1 -1
- package/dist/emails/index.js +3 -1
- package/dist/emails/index.js.map +1 -1
- package/dist/emails/send/admin-auth.d.ts +94 -0
- package/dist/emails/send/admin-auth.d.ts.map +1 -0
- package/dist/emails/send/admin-auth.js +118 -0
- package/dist/emails/send/admin-auth.js.map +1 -0
- package/dist/emails/send/index.d.ts +2 -0
- package/dist/emails/send/index.d.ts.map +1 -1
- package/dist/emails/send/index.js +4 -0
- package/dist/emails/send/index.js.map +1 -1
- package/dist/emails/templates/admin-invite.d.ts +40 -0
- package/dist/emails/templates/admin-invite.d.ts.map +1 -0
- package/dist/emails/templates/admin-invite.js +62 -0
- package/dist/emails/templates/admin-invite.js.map +1 -0
- package/dist/emails/templates/index.d.ts +1 -0
- package/dist/emails/templates/index.d.ts.map +1 -1
- package/dist/emails/templates/index.js +4 -0
- package/dist/emails/templates/index.js.map +1 -1
- package/dist/emails/types.d.ts +22 -1
- package/dist/emails/types.d.ts.map +1 -1
- 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
|