@varshylinc/team-management 0.1.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 (203) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/CHANGELOG.md +159 -0
  3. package/LICENSE +6 -0
  4. package/README.md +97 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +6 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/server/crypto.d.ts +6 -0
  10. package/dist/server/crypto.d.ts.map +1 -0
  11. package/dist/server/crypto.js +42 -0
  12. package/dist/server/crypto.js.map +1 -0
  13. package/dist/server/index.d.ts +34 -0
  14. package/dist/server/index.d.ts.map +1 -0
  15. package/dist/server/index.js +114 -0
  16. package/dist/server/index.js.map +1 -0
  17. package/dist/server/middleware/require-membership.d.ts +10 -0
  18. package/dist/server/middleware/require-membership.d.ts.map +1 -0
  19. package/dist/server/middleware/require-membership.js +33 -0
  20. package/dist/server/middleware/require-membership.js.map +1 -0
  21. package/dist/server/middleware/require-role.d.ts +4 -0
  22. package/dist/server/middleware/require-role.d.ts.map +1 -0
  23. package/dist/server/middleware/require-role.js +16 -0
  24. package/dist/server/middleware/require-role.js.map +1 -0
  25. package/dist/server/middleware/require-super-admin.d.ts +5 -0
  26. package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
  27. package/dist/server/middleware/require-super-admin.js +27 -0
  28. package/dist/server/middleware/require-super-admin.js.map +1 -0
  29. package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
  30. package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
  31. package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
  32. package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
  33. package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
  34. package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
  35. package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
  36. package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
  37. package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
  38. package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
  39. package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
  40. package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
  41. package/dist/server/routes/admin.routes.d.ts +5 -0
  42. package/dist/server/routes/admin.routes.d.ts.map +1 -0
  43. package/dist/server/routes/admin.routes.js +262 -0
  44. package/dist/server/routes/admin.routes.js.map +1 -0
  45. package/dist/server/routes/audit.routes.d.ts +5 -0
  46. package/dist/server/routes/audit.routes.d.ts.map +1 -0
  47. package/dist/server/routes/audit.routes.js +70 -0
  48. package/dist/server/routes/audit.routes.js.map +1 -0
  49. package/dist/server/routes/health.routes.d.ts +8 -0
  50. package/dist/server/routes/health.routes.d.ts.map +1 -0
  51. package/dist/server/routes/health.routes.js +39 -0
  52. package/dist/server/routes/health.routes.js.map +1 -0
  53. package/dist/server/routes/invitations.routes.d.ts +5 -0
  54. package/dist/server/routes/invitations.routes.d.ts.map +1 -0
  55. package/dist/server/routes/invitations.routes.js +232 -0
  56. package/dist/server/routes/invitations.routes.js.map +1 -0
  57. package/dist/server/routes/me.routes.d.ts +5 -0
  58. package/dist/server/routes/me.routes.d.ts.map +1 -0
  59. package/dist/server/routes/me.routes.js +188 -0
  60. package/dist/server/routes/me.routes.js.map +1 -0
  61. package/dist/server/routes/orgs.routes.d.ts +5 -0
  62. package/dist/server/routes/orgs.routes.d.ts.map +1 -0
  63. package/dist/server/routes/orgs.routes.js +371 -0
  64. package/dist/server/routes/orgs.routes.js.map +1 -0
  65. package/dist/server/routes/transfer.routes.d.ts +5 -0
  66. package/dist/server/routes/transfer.routes.d.ts.map +1 -0
  67. package/dist/server/routes/transfer.routes.js +108 -0
  68. package/dist/server/routes/transfer.routes.js.map +1 -0
  69. package/dist/server/services/audit.service.d.ts +20 -0
  70. package/dist/server/services/audit.service.d.ts.map +1 -0
  71. package/dist/server/services/audit.service.js +23 -0
  72. package/dist/server/services/audit.service.js.map +1 -0
  73. package/dist/server/services/email-change.service.d.ts +16 -0
  74. package/dist/server/services/email-change.service.d.ts.map +1 -0
  75. package/dist/server/services/email-change.service.js +107 -0
  76. package/dist/server/services/email-change.service.js.map +1 -0
  77. package/dist/server/services/invitations.service.d.ts +41 -0
  78. package/dist/server/services/invitations.service.d.ts.map +1 -0
  79. package/dist/server/services/invitations.service.js +214 -0
  80. package/dist/server/services/invitations.service.js.map +1 -0
  81. package/dist/server/services/memberships.service.d.ts +27 -0
  82. package/dist/server/services/memberships.service.d.ts.map +1 -0
  83. package/dist/server/services/memberships.service.js +69 -0
  84. package/dist/server/services/memberships.service.js.map +1 -0
  85. package/dist/server/services/organizations.service.d.ts +19 -0
  86. package/dist/server/services/organizations.service.d.ts.map +1 -0
  87. package/dist/server/services/organizations.service.js +61 -0
  88. package/dist/server/services/organizations.service.js.map +1 -0
  89. package/dist/server/services/ownership.service.d.ts +19 -0
  90. package/dist/server/services/ownership.service.d.ts.map +1 -0
  91. package/dist/server/services/ownership.service.js +102 -0
  92. package/dist/server/services/ownership.service.js.map +1 -0
  93. package/dist/server/services/password-reset.service.d.ts +12 -0
  94. package/dist/server/services/password-reset.service.d.ts.map +1 -0
  95. package/dist/server/services/password-reset.service.js +54 -0
  96. package/dist/server/services/password-reset.service.js.map +1 -0
  97. package/dist/server/services/super-admin.service.d.ts +59 -0
  98. package/dist/server/services/super-admin.service.d.ts.map +1 -0
  99. package/dist/server/services/super-admin.service.js +187 -0
  100. package/dist/server/services/super-admin.service.js.map +1 -0
  101. package/dist/server/types.d.ts +186 -0
  102. package/dist/server/types.d.ts.map +1 -0
  103. package/dist/server/types.js +6 -0
  104. package/dist/server/types.js.map +1 -0
  105. package/dist/shared/types.d.ts +23 -0
  106. package/dist/shared/types.d.ts.map +1 -0
  107. package/dist/shared/types.js +6 -0
  108. package/dist/shared/types.js.map +1 -0
  109. package/package.json +56 -0
  110. package/src/client/api.ts +314 -0
  111. package/src/client/components/AuditEventRow.tsx +59 -0
  112. package/src/client/components/CascadePreview.tsx +36 -0
  113. package/src/client/components/DangerZoneCard.tsx +103 -0
  114. package/src/client/components/InvitationCodeDisplay.tsx +48 -0
  115. package/src/client/components/InviteForm.tsx +77 -0
  116. package/src/client/components/MemberRow.tsx +69 -0
  117. package/src/client/components/PendingTransferBanner.tsx +98 -0
  118. package/src/client/components/PlaceholderCard.tsx +26 -0
  119. package/src/client/components/RoleBadge.tsx +26 -0
  120. package/src/client/components/RoleSelect.tsx +35 -0
  121. package/src/client/hooks/.gitkeep +0 -0
  122. package/src/client/hooks/useCurrentMembership.ts +24 -0
  123. package/src/client/hooks/useMembers.ts +24 -0
  124. package/src/client/hooks/usePendingInvitations.ts +24 -0
  125. package/src/client/hooks/usePendingTransfer.ts +27 -0
  126. package/src/client/index.ts +80 -0
  127. package/src/client/pages/AuditLogPage.tsx +164 -0
  128. package/src/client/pages/EmailChangePage.tsx +144 -0
  129. package/src/client/pages/InvitationAcceptPage.tsx +163 -0
  130. package/src/client/pages/InvitationCodePage.tsx +108 -0
  131. package/src/client/pages/MembersPage.tsx +290 -0
  132. package/src/client/pages/OrgSettingsPage.tsx +185 -0
  133. package/src/client/pages/OwnershipTransferPage.tsx +163 -0
  134. package/src/client/pages/PasswordResetPage.tsx +104 -0
  135. package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
  136. package/src/client/pages/PlaceholderPage.tsx +20 -0
  137. package/src/client/pages/SuperAdminDashboard.tsx +401 -0
  138. package/src/client/types.ts +78 -0
  139. package/src/index.ts +24 -0
  140. package/src/server/crypto.ts +47 -0
  141. package/src/server/index.ts +167 -0
  142. package/src/server/middleware/require-membership.ts +48 -0
  143. package/src/server/middleware/require-role.ts +19 -0
  144. package/src/server/middleware/require-super-admin.ts +32 -0
  145. package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
  146. package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
  147. package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
  148. package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
  149. package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
  150. package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
  151. package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
  152. package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
  153. package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
  154. package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
  155. package/src/server/migrations/0011_seed_super_admin.sql +15 -0
  156. package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
  157. package/src/server/routes/admin.routes.ts +208 -0
  158. package/src/server/routes/audit.routes.ts +93 -0
  159. package/src/server/routes/health.routes.ts +46 -0
  160. package/src/server/routes/invitations.routes.ts +252 -0
  161. package/src/server/routes/me.routes.ts +143 -0
  162. package/src/server/routes/orgs.routes.ts +428 -0
  163. package/src/server/routes/transfer.routes.ts +110 -0
  164. package/src/server/services/.gitkeep +0 -0
  165. package/src/server/services/audit.service.ts +49 -0
  166. package/src/server/services/email-change.service.ts +178 -0
  167. package/src/server/services/invitations.service.ts +316 -0
  168. package/src/server/services/memberships.service.ts +129 -0
  169. package/src/server/services/organizations.service.ts +110 -0
  170. package/src/server/services/ownership.service.ts +170 -0
  171. package/src/server/services/password-reset.service.ts +94 -0
  172. package/src/server/services/super-admin.service.ts +321 -0
  173. package/src/server/sql/.gitkeep +0 -0
  174. package/src/server/types.ts +145 -0
  175. package/src/shared/types.ts +24 -0
  176. package/tests/integration/audit-fires.test.ts +288 -0
  177. package/tests/integration/cascade-preview.test.ts +157 -0
  178. package/tests/integration/email-change.test.ts +190 -0
  179. package/tests/integration/feature-flags.test.ts +213 -0
  180. package/tests/integration/invitations-code.test.ts +218 -0
  181. package/tests/integration/invitations-expiry.test.ts +216 -0
  182. package/tests/integration/invitations-resend.test.ts +241 -0
  183. package/tests/integration/invitations-revoke.test.ts +226 -0
  184. package/tests/integration/invitations-switch-org.test.ts +156 -0
  185. package/tests/integration/invitations-token.test.ts +221 -0
  186. package/tests/integration/migrations.test.ts +119 -0
  187. package/tests/integration/only-owner-protections.test.ts +130 -0
  188. package/tests/integration/org-lifecycle.test.ts +169 -0
  189. package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
  190. package/tests/integration/ownership-transfer-expire.test.ts +171 -0
  191. package/tests/integration/ownership-transfer-happy.test.ts +184 -0
  192. package/tests/integration/ownership-transfer-locks.test.ts +146 -0
  193. package/tests/integration/password-reset.test.ts +200 -0
  194. package/tests/integration/super-admin-actions.test.ts +180 -0
  195. package/tests/integration/super-admin-restrictions.test.ts +209 -0
  196. package/tests/setup/global-setup.ts +20 -0
  197. package/tests/unit/adapter-shape.test.ts +330 -0
  198. package/tests/unit/role-permissions.test.ts +236 -0
  199. package/tests/unit/validation.test.ts +304 -0
  200. package/tsconfig.client.json +13 -0
  201. package/tsconfig.json +12 -0
  202. package/tsconfig.tsbuildinfo +1 -0
  203. package/vitest.config.ts +13 -0
@@ -0,0 +1,208 @@
1
+ import { Router } from 'express';
2
+ import type { Pool } from 'pg';
3
+ import type { ServerModuleAdapter, TeamManagementFeatureFlags, OrgRole } from '../types.js';
4
+ import { requireSuperAdmin } from '../middleware/require-super-admin.js';
5
+ import {
6
+ listAllOrgs,
7
+ getOrgForAdmin,
8
+ getUserForAdmin,
9
+ restoreOrg,
10
+ appointOwner,
11
+ hardDeleteOrg,
12
+ addMemberAdmin,
13
+ removeMemberAdmin,
14
+ lockUser,
15
+ unlockUser,
16
+ triggerPasswordReset,
17
+ } from '../services/super-admin.service.js';
18
+
19
+ export function createAdminRouter(
20
+ pool: Pool,
21
+ adapter: ServerModuleAdapter,
22
+ flags: TeamManagementFeatureFlags,
23
+ baseUrl: string
24
+ ): Router {
25
+ const router = Router();
26
+ const superAdminMiddleware = requireSuperAdmin(pool, adapter, flags);
27
+
28
+ type SARequest = import('express').Request & { superAdminUserId: number };
29
+
30
+ // GET /admin/orgs
31
+ router.get('/orgs', superAdminMiddleware, async (req, res) => {
32
+ try {
33
+ const orgs = await listAllOrgs(pool);
34
+ res.json({ orgs });
35
+ } catch (e) {
36
+ adapter.logger.error('[admin] GET /orgs', { error: (e as Error).message });
37
+ res.status(500).json({ error: 'Failed to list organizations' });
38
+ }
39
+ });
40
+
41
+ // GET /admin/orgs/:id
42
+ router.get('/orgs/:id', superAdminMiddleware, async (req, res) => {
43
+ const orgId = parseInt(req.params.id, 10);
44
+ if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid org ID' }); return; }
45
+ try {
46
+ const org = await getOrgForAdmin(pool, orgId);
47
+ res.json({ org });
48
+ } catch (e) {
49
+ const msg = (e as Error).message;
50
+ adapter.logger.error('[admin] GET /orgs/:id', { error: msg });
51
+ if (msg.includes('not found')) { res.status(404).json({ error: msg }); }
52
+ else { res.status(500).json({ error: 'Failed to fetch organization' }); }
53
+ }
54
+ });
55
+
56
+ // GET /admin/users/:id
57
+ router.get('/users/:id', superAdminMiddleware, async (req, res) => {
58
+ const userId = parseInt(req.params.id, 10);
59
+ if (isNaN(userId)) { res.status(400).json({ error: 'Invalid user ID' }); return; }
60
+ try {
61
+ const user = await getUserForAdmin(pool, adapter, userId);
62
+ res.json({ user });
63
+ } catch (e) {
64
+ const msg = (e as Error).message;
65
+ adapter.logger.error('[admin] GET /users/:id', { error: msg });
66
+ if (msg.includes('not found')) { res.status(404).json({ error: msg }); }
67
+ else { res.status(500).json({ error: 'Failed to fetch user' }); }
68
+ }
69
+ });
70
+
71
+ // POST /admin/orgs/:id/restore
72
+ router.post('/orgs/:id/restore', superAdminMiddleware, async (req, res) => {
73
+ const orgId = parseInt(req.params.id, 10);
74
+ if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid org ID' }); return; }
75
+ const { reason } = req.body as { reason?: string };
76
+ if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
77
+ const saUserId = (req as SARequest).superAdminUserId;
78
+ try {
79
+ await restoreOrg(pool, { orgId, superAdminUserId: saUserId, reason });
80
+ res.json({ message: 'Organization restored' });
81
+ } catch (e) {
82
+ const msg = (e as Error).message;
83
+ adapter.logger.error('[admin] POST /orgs/:id/restore', { error: msg });
84
+ if (msg.includes('not found') || msg.includes('not deleted')) { res.status(404).json({ error: msg }); }
85
+ else { res.status(500).json({ error: 'Failed to restore organization' }); }
86
+ }
87
+ });
88
+
89
+ // POST /admin/orgs/:id/appoint-owner
90
+ router.post('/orgs/:id/appoint-owner', superAdminMiddleware, async (req, res) => {
91
+ const orgId = parseInt(req.params.id, 10);
92
+ if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid org ID' }); return; }
93
+ const { targetUserId, reason } = req.body as { targetUserId?: number; reason?: string };
94
+ if (!targetUserId || !reason) { res.status(400).json({ error: 'targetUserId and reason are required' }); return; }
95
+ const saUserId = (req as SARequest).superAdminUserId;
96
+ try {
97
+ await appointOwner(pool, { orgId, targetUserId, superAdminUserId: saUserId, reason });
98
+ res.json({ message: 'Owner appointed' });
99
+ } catch (e) {
100
+ adapter.logger.error('[admin] POST /orgs/:id/appoint-owner', { error: (e as Error).message });
101
+ res.status(500).json({ error: 'Failed to appoint owner' });
102
+ }
103
+ });
104
+
105
+ // POST /admin/orgs/:id/hard-delete
106
+ router.post('/orgs/:id/hard-delete', superAdminMiddleware, async (req, res) => {
107
+ if (!flags.enableHardDelete) { res.status(403).json({ error: 'Hard delete is not enabled' }); return; }
108
+ const orgId = parseInt(req.params.id, 10);
109
+ if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid org ID' }); return; }
110
+ const { legalBasis } = req.body as { legalBasis?: string };
111
+ if (!legalBasis) { res.status(400).json({ error: 'legalBasis is required' }); return; }
112
+ const saUserId = (req as SARequest).superAdminUserId;
113
+ try {
114
+ await hardDeleteOrg(pool, { orgId, superAdminUserId: saUserId, legalBasis });
115
+ res.json({ message: 'Organization permanently deleted' });
116
+ } catch (e) {
117
+ const msg = (e as Error).message;
118
+ adapter.logger.error('[admin] POST /orgs/:id/hard-delete', { error: msg });
119
+ if (msg.includes('legal basis')) { res.status(400).json({ error: msg }); }
120
+ else { res.status(500).json({ error: 'Failed to hard delete organization' }); }
121
+ }
122
+ });
123
+
124
+ // POST /admin/orgs/:id/members/add
125
+ router.post('/orgs/:id/members/add', superAdminMiddleware, async (req, res) => {
126
+ const orgId = parseInt(req.params.id, 10);
127
+ if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid org ID' }); return; }
128
+ const { userId, role, reason } = req.body as { userId?: number; role?: string; reason?: string };
129
+ if (!userId || !role || !reason) { res.status(400).json({ error: 'userId, role, and reason are required' }); return; }
130
+ const saUserId = (req as SARequest).superAdminUserId;
131
+ try {
132
+ await addMemberAdmin(pool, { orgId, userId, role: role as OrgRole, superAdminUserId: saUserId, reason });
133
+ res.json({ message: 'Member added' });
134
+ } catch (e) {
135
+ adapter.logger.error('[admin] POST /orgs/:id/members/add', { error: (e as Error).message });
136
+ res.status(500).json({ error: 'Failed to add member' });
137
+ }
138
+ });
139
+
140
+ // POST /admin/orgs/:id/members/remove
141
+ router.post('/orgs/:id/members/remove', superAdminMiddleware, async (req, res) => {
142
+ const orgId = parseInt(req.params.id, 10);
143
+ if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid org ID' }); return; }
144
+ const { userId, reason } = req.body as { userId?: number; reason?: string };
145
+ if (!userId || !reason) { res.status(400).json({ error: 'userId and reason are required' }); return; }
146
+ const saUserId = (req as SARequest).superAdminUserId;
147
+ try {
148
+ await removeMemberAdmin(pool, { orgId, userId, superAdminUserId: saUserId, reason });
149
+ res.json({ message: 'Member removed' });
150
+ } catch (e) {
151
+ adapter.logger.error('[admin] POST /orgs/:id/members/remove', { error: (e as Error).message });
152
+ res.status(500).json({ error: 'Failed to remove member' });
153
+ }
154
+ });
155
+
156
+ // POST /admin/users/:id/lock
157
+ router.post('/users/:id/lock', superAdminMiddleware, async (req, res) => {
158
+ const userId = parseInt(req.params.id, 10);
159
+ if (isNaN(userId)) { res.status(400).json({ error: 'Invalid user ID' }); return; }
160
+ const { reason } = req.body as { reason?: string };
161
+ if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
162
+ const saUserId = (req as SARequest).superAdminUserId;
163
+ try {
164
+ await lockUser(pool, adapter, { userId, superAdminUserId: saUserId, reason });
165
+ res.json({ message: 'User locked and sessions invalidated' });
166
+ } catch (e) {
167
+ adapter.logger.error('[admin] POST /users/:id/lock', { error: (e as Error).message });
168
+ res.status(500).json({ error: 'Failed to lock user' });
169
+ }
170
+ });
171
+
172
+ // POST /admin/users/:id/unlock
173
+ router.post('/users/:id/unlock', superAdminMiddleware, async (req, res) => {
174
+ const userId = parseInt(req.params.id, 10);
175
+ if (isNaN(userId)) { res.status(400).json({ error: 'Invalid user ID' }); return; }
176
+ const { reason } = req.body as { reason?: string };
177
+ if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
178
+ const saUserId = (req as SARequest).superAdminUserId;
179
+ try {
180
+ await unlockUser(pool, adapter, { userId, superAdminUserId: saUserId, reason });
181
+ res.json({ message: 'User unlocked' });
182
+ } catch (e) {
183
+ adapter.logger.error('[admin] POST /users/:id/unlock', { error: (e as Error).message });
184
+ res.status(500).json({ error: 'Failed to unlock user' });
185
+ }
186
+ });
187
+
188
+ // POST /admin/users/:id/password-reset
189
+ router.post('/users/:id/password-reset', superAdminMiddleware, async (req, res) => {
190
+ const userId = parseInt(req.params.id, 10);
191
+ if (isNaN(userId)) { res.status(400).json({ error: 'Invalid user ID' }); return; }
192
+ const { reason } = req.body as { reason?: string };
193
+ if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
194
+ const saUserId = (req as SARequest).superAdminUserId;
195
+ try {
196
+ await triggerPasswordReset(pool, adapter, { userId, superAdminUserId: saUserId, reason, baseUrl });
197
+ res.json({ message: 'Password reset email sent' });
198
+ } catch (e) {
199
+ const msg = (e as Error).message;
200
+ adapter.logger.error('[admin] POST /users/:id/password-reset', { error: msg });
201
+ if (msg.includes('not found')) { res.status(404).json({ error: msg }); }
202
+ else { res.status(500).json({ error: 'Failed to trigger password reset' }); }
203
+ }
204
+ });
205
+
206
+ return router;
207
+ }
208
+
@@ -0,0 +1,93 @@
1
+ import { Router } from 'express';
2
+ import type { Pool } from 'pg';
3
+ import type { ServerModuleAdapter, TeamManagementFeatureFlags } from '../types.js';
4
+ import { requireMembership, type AuthenticatedRequest } from '../middleware/require-membership.js';
5
+ import { requireRole } from '../middleware/require-role.js';
6
+
7
+ const SUPER_ADMIN_DISPLAY_NAME = 'Varshyl Support';
8
+ const DEFAULT_PAGE_LIMIT = 50;
9
+ const MAX_PAGE_LIMIT = 200;
10
+
11
+ export function createAuditRouter(
12
+ pool: Pool,
13
+ adapter: ServerModuleAdapter,
14
+ flags: TeamManagementFeatureFlags
15
+ ): Router {
16
+ const router = Router({ mergeParams: true });
17
+ const authMiddleware = requireMembership(pool, adapter);
18
+
19
+ // GET /orgs/:orgId/audit — paginated audit log (admin+)
20
+ router.get('/:orgId/audit', authMiddleware, requireRole('admin'), async (req, res) => {
21
+ if (!flags.enableAuditLog) {
22
+ res.status(501).json({ error: 'Audit log is not enabled' });
23
+ return;
24
+ }
25
+
26
+ const { orgId } = req as AuthenticatedRequest;
27
+ const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
28
+ const rawLimit = parseInt(req.query.limit as string, 10) || DEFAULT_PAGE_LIMIT;
29
+ const limit = Math.min(rawLimit, MAX_PAGE_LIMIT);
30
+ const offset = (page - 1) * limit;
31
+ const action = req.query.action as string | undefined;
32
+
33
+ try {
34
+ const params: unknown[] = [orgId, limit, offset];
35
+ let actionFilter = '';
36
+ if (action) {
37
+ params.push(action);
38
+ actionFilter = `AND ae.action = $${params.length}`;
39
+ }
40
+
41
+ const result = await pool.query(
42
+ `SELECT ae.*, sa.user_id AS is_super_admin_actor
43
+ FROM tm_audit_events ae
44
+ LEFT JOIN tm_super_admins sa ON sa.user_id = ae.actor_user_id AND sa.revoked_at IS NULL
45
+ WHERE ae.org_id = $1
46
+ ${actionFilter}
47
+ ORDER BY ae.created_at DESC
48
+ LIMIT $2 OFFSET $3`,
49
+ params
50
+ );
51
+
52
+ const countResult = await pool.query(
53
+ `SELECT COUNT(*) FROM tm_audit_events WHERE org_id = $1 ${action ? 'AND action = $2' : ''}`,
54
+ action ? [orgId, action] : [orgId]
55
+ );
56
+ const total = parseInt(countResult.rows[0].count, 10);
57
+
58
+ // Collect user IDs for enrichment (non-super-admin actors)
59
+ const userIds = [
60
+ ...new Set(
61
+ result.rows
62
+ .filter(r => r.actor_user_id && !r.is_super_admin_actor)
63
+ .map(r => r.actor_user_id as number)
64
+ ),
65
+ ];
66
+ const users = userIds.length > 0 ? await adapter.getUsersByIds(userIds) : [];
67
+ const userMap = new Map(users.map(u => [u.id, u]));
68
+
69
+ const events = result.rows.map(row => {
70
+ const { is_super_admin_actor, ...event } = row;
71
+ const actorDisplay = is_super_admin_actor
72
+ ? { id: row.actor_user_id, name: SUPER_ADMIN_DISPLAY_NAME, email: null }
73
+ : userMap.get(row.actor_user_id) ?? { id: row.actor_user_id, name: null, email: null };
74
+ return { ...event, actor: actorDisplay };
75
+ });
76
+
77
+ res.json({
78
+ events,
79
+ pagination: {
80
+ page,
81
+ limit,
82
+ total,
83
+ totalPages: Math.ceil(total / limit),
84
+ },
85
+ });
86
+ } catch (e) {
87
+ adapter.logger.error('[audit] GET /:orgId/audit', { error: (e as Error).message });
88
+ res.status(500).json({ error: 'Failed to fetch audit log' });
89
+ }
90
+ });
91
+
92
+ return router;
93
+ }
@@ -0,0 +1,46 @@
1
+ import { Router } from 'express';
2
+ import type { Pool } from 'pg';
3
+ import type { TeamManagementConfig } from '../types.js';
4
+
5
+ const MODULE_VERSION = '0.1.0';
6
+
7
+ export function createHealthRouter(
8
+ pool: Pool,
9
+ config: TeamManagementConfig
10
+ ): { router: Router; handler: import('express').RequestHandler } {
11
+ const flags = config.featureFlags ?? {};
12
+
13
+ const handler: import('express').RequestHandler = async (_req, res) => {
14
+ try {
15
+ await pool.query('SELECT 1');
16
+ res.json({
17
+ status: 'ok',
18
+ module: '@varshylinc/team-management',
19
+ version: MODULE_VERSION,
20
+ db: 'connected',
21
+ flags: {
22
+ enableInvites: flags.enableInvites ?? true,
23
+ enableAuditLog: flags.enableAuditLog ?? true,
24
+ enableOwnershipTransfer: flags.enableOwnershipTransfer ?? true,
25
+ enableEmailChange: flags.enableEmailChange ?? true,
26
+ enablePasswordReset: flags.enablePasswordReset ?? true,
27
+ enableSuperAdmin: flags.enableSuperAdmin ?? false,
28
+ enableSharedAccess: flags.enableSharedAccess ?? false,
29
+ enableHardDelete: flags.enableHardDelete ?? false,
30
+ },
31
+ });
32
+ } catch (err) {
33
+ res.status(503).json({
34
+ status: 'error',
35
+ module: '@varshylinc/team-management',
36
+ version: MODULE_VERSION,
37
+ db: 'disconnected',
38
+ error: err instanceof Error ? err.message : 'Unknown error',
39
+ });
40
+ }
41
+ };
42
+
43
+ const router = Router();
44
+ router.get('/health', handler);
45
+ return { router, handler };
46
+ }
@@ -0,0 +1,252 @@
1
+ import { Router } from 'express';
2
+ import type { Pool } from 'pg';
3
+ import type { ServerModuleAdapter, TeamManagementFeatureFlags, OrgRole } from '../types.js';
4
+ import { requireMembership, type AuthenticatedRequest } from '../middleware/require-membership.js';
5
+ import { requireRole } from '../middleware/require-role.js';
6
+ import {
7
+ createInvitation,
8
+ revokeInvitation,
9
+ resendInvitation,
10
+ listPendingInvitations,
11
+ getInvitationWithDecryptedCode,
12
+ acceptInvitationByToken,
13
+ acceptInvitationByCode,
14
+ } from '../services/invitations.service.js';
15
+ import { writeAuditEvent, getClientIp } from '../services/audit.service.js';
16
+
17
+ export function createInvitationsRouter(
18
+ pool: Pool,
19
+ adapter: ServerModuleAdapter,
20
+ flags: TeamManagementFeatureFlags,
21
+ baseUrl: string
22
+ ): Router {
23
+ const router = Router({ mergeParams: true });
24
+
25
+ function featureCheck(res: import('express').Response): boolean {
26
+ if (!flags.enableInvites) {
27
+ res.status(501).json({ error: 'Invitations feature is not enabled' });
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ const authMiddleware = requireMembership(pool, adapter);
34
+
35
+ // GET /orgs/:orgId/invitations — list pending (admin+)
36
+ router.get('/:orgId/invitations', authMiddleware, requireRole('admin'), async (req, res) => {
37
+ if (!featureCheck(res)) return;
38
+ const { orgId } = req as AuthenticatedRequest;
39
+ try {
40
+ const invitations = await listPendingInvitations(pool, orgId);
41
+ res.json({ invitations: invitations.map(inv => ({ ...inv, status: 'pending' })) });
42
+ } catch (e) {
43
+ adapter.logger.error('[invitations] GET list', { error: (e as Error).message });
44
+ res.status(500).json({ error: 'Failed to fetch invitations' });
45
+ }
46
+ });
47
+
48
+ // POST /orgs/:orgId/invitations — create (admin+)
49
+ router.post('/:orgId/invitations', authMiddleware, requireRole('admin'), async (req, res) => {
50
+ if (!featureCheck(res)) return;
51
+ const { orgId, userId } = req as AuthenticatedRequest;
52
+ const { email, role } = req.body as { email?: string; role?: string };
53
+
54
+ if (!email || !role) {
55
+ res.status(400).json({ error: 'email and role are required' });
56
+ return;
57
+ }
58
+
59
+ try {
60
+ const { invitation } = await createInvitation(pool, adapter, {
61
+ orgId,
62
+ invitedByUserId: userId,
63
+ email,
64
+ role: role as OrgRole,
65
+ baseUrl,
66
+ });
67
+
68
+ if (flags.enableAuditLog) {
69
+ await writeAuditEvent({
70
+ pool,
71
+ orgId,
72
+ actorUserId: userId,
73
+ action: 'member.invited',
74
+ targetType: 'invitation',
75
+ targetId: invitation.id,
76
+ after: { email, role },
77
+ ip: getClientIp(req),
78
+ userAgent: req.headers['user-agent'] ?? null,
79
+ });
80
+ }
81
+
82
+ res.status(201).json({ invitation: { ...invitation, status: 'pending' } });
83
+ } catch (e) {
84
+ const msg = (e as Error).message;
85
+ adapter.logger.error('[invitations] POST create', { error: msg });
86
+ if (msg.includes('already exists')) {
87
+ res.status(409).json({ error: msg });
88
+ } else {
89
+ res.status(500).json({ error: 'Failed to create invitation' });
90
+ }
91
+ }
92
+ });
93
+
94
+ // DELETE /orgs/:orgId/invitations/:id — revoke (admin+)
95
+ router.delete('/:orgId/invitations/:id', authMiddleware, requireRole('admin'), async (req, res) => {
96
+ if (!featureCheck(res)) return;
97
+ const { orgId, userId } = req as AuthenticatedRequest;
98
+ const invitationId = parseInt(req.params.id, 10);
99
+ if (isNaN(invitationId)) {
100
+ res.status(400).json({ error: 'Invalid invitation ID' });
101
+ return;
102
+ }
103
+ try {
104
+ await revokeInvitation(pool, { invitationId, revokedByUserId: userId });
105
+
106
+ if (flags.enableAuditLog) {
107
+ await writeAuditEvent({
108
+ pool,
109
+ orgId,
110
+ actorUserId: userId,
111
+ action: 'member.invite_revoked',
112
+ targetType: 'invitation',
113
+ targetId: invitationId,
114
+ ip: getClientIp(req),
115
+ userAgent: req.headers['user-agent'] ?? null,
116
+ });
117
+ }
118
+
119
+ res.json({ message: 'Invitation revoked' });
120
+ } catch (e) {
121
+ adapter.logger.error('[invitations] DELETE revoke', { error: (e as Error).message });
122
+ res.status(500).json({ error: 'Failed to revoke invitation' });
123
+ }
124
+ });
125
+
126
+ // POST /orgs/:orgId/invitations/:id/resend — resend (admin+)
127
+ router.post('/:orgId/invitations/:id/resend', authMiddleware, requireRole('admin'), async (req, res) => {
128
+ if (!featureCheck(res)) return;
129
+ const { orgId, userId } = req as AuthenticatedRequest;
130
+ const invitationId = parseInt(req.params.id, 10);
131
+ if (isNaN(invitationId)) {
132
+ res.status(400).json({ error: 'Invalid invitation ID' });
133
+ return;
134
+ }
135
+ try {
136
+ await resendInvitation(pool, adapter, { invitationId, baseUrl });
137
+
138
+ if (flags.enableAuditLog) {
139
+ await writeAuditEvent({
140
+ pool,
141
+ orgId,
142
+ actorUserId: userId,
143
+ action: 'org.invitation_resent',
144
+ targetType: 'invitation',
145
+ targetId: invitationId,
146
+ ip: getClientIp(req),
147
+ userAgent: req.headers['user-agent'] ?? null,
148
+ });
149
+ }
150
+
151
+ res.json({ message: 'Invitation resent' });
152
+ } catch (e) {
153
+ adapter.logger.error('[invitations] POST resend', { error: (e as Error).message });
154
+ res.status(500).json({ error: 'Failed to resend invitation' });
155
+ }
156
+ });
157
+
158
+ // GET /orgs/:orgId/invitations/:id/code — get decrypted code (admin+, phone fallback)
159
+ router.get('/:orgId/invitations/:id/code', authMiddleware, requireRole('admin'), async (req, res) => {
160
+ if (!featureCheck(res)) return;
161
+ const invitationId = parseInt(req.params.id, 10);
162
+ if (isNaN(invitationId)) {
163
+ res.status(400).json({ error: 'Invalid invitation ID' });
164
+ return;
165
+ }
166
+ try {
167
+ const result = await getInvitationWithDecryptedCode(pool, invitationId);
168
+ res.json({ code: result.code, expiresAt: result.expires_at });
169
+ } catch (e) {
170
+ adapter.logger.error('[invitations] GET code', { error: (e as Error).message });
171
+ res.status(500).json({ error: 'Failed to retrieve invitation code' });
172
+ }
173
+ });
174
+
175
+ // POST /invitations/accept/token — public, token in body
176
+ router.post('/accept/token', async (req, res) => {
177
+ if (!featureCheck(res)) return;
178
+ const { token } = req.body as { token?: string };
179
+ if (!token) {
180
+ res.status(400).json({ error: 'token is required' });
181
+ return;
182
+ }
183
+ try {
184
+ const userId = await adapter.getCurrentUserId(req as import('express').Request);
185
+ const result = await acceptInvitationByToken(pool, adapter, { token, acceptingUserId: userId ?? undefined });
186
+
187
+ if (flags.enableAuditLog) {
188
+ await writeAuditEvent({
189
+ pool,
190
+ orgId: result.orgId,
191
+ actorUserId: userId ?? null,
192
+ action: 'member.invite_accepted',
193
+ targetType: 'org',
194
+ targetId: result.orgId,
195
+ after: { role: result.role },
196
+ ip: getClientIp(req),
197
+ userAgent: req.headers['user-agent'] ?? null,
198
+ });
199
+ }
200
+
201
+ res.json({ message: 'Invitation accepted', orgId: result.orgId, role: result.role });
202
+ } catch (e) {
203
+ const msg = (e as Error).message;
204
+ adapter.logger.error('[invitations] POST accept/token', { error: msg });
205
+ if (msg.includes('not found') || msg.includes('expired') || msg.includes('used')) {
206
+ res.status(404).json({ error: msg });
207
+ } else {
208
+ res.status(500).json({ error: 'Failed to accept invitation' });
209
+ }
210
+ }
211
+ });
212
+
213
+ // POST /invitations/accept/code — public, code in body
214
+ router.post('/accept/code', async (req, res) => {
215
+ if (!featureCheck(res)) return;
216
+ const { email, code } = req.body as { email?: string; code?: string };
217
+ if (!email || !code) {
218
+ res.status(400).json({ error: 'email and code are required' });
219
+ return;
220
+ }
221
+ try {
222
+ const userId = await adapter.getCurrentUserId(req as import('express').Request);
223
+ const result = await acceptInvitationByCode(pool, adapter, { email, code, acceptingUserId: userId ?? undefined });
224
+
225
+ if (flags.enableAuditLog) {
226
+ await writeAuditEvent({
227
+ pool,
228
+ orgId: result.orgId,
229
+ actorUserId: userId ?? null,
230
+ action: 'member.invite_accepted',
231
+ targetType: 'org',
232
+ targetId: result.orgId,
233
+ after: { role: result.role },
234
+ ip: getClientIp(req),
235
+ userAgent: req.headers['user-agent'] ?? null,
236
+ });
237
+ }
238
+
239
+ res.json({ message: 'Invitation accepted', orgId: result.orgId, role: result.role });
240
+ } catch (e) {
241
+ const msg = (e as Error).message;
242
+ adapter.logger.error('[invitations] POST accept/code', { error: msg });
243
+ if (msg.includes('not found') || msg.includes('Invalid') || msg.includes('No valid')) {
244
+ res.status(404).json({ error: msg });
245
+ } else {
246
+ res.status(500).json({ error: 'Failed to accept invitation' });
247
+ }
248
+ }
249
+ });
250
+
251
+ return router;
252
+ }