@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,110 @@
1
+ import type { Pool } from 'pg';
2
+ import type { TmOrganization, TmMembership } from '../types.js';
3
+
4
+ export async function createOrg(
5
+ pool: Pool,
6
+ { name, slug, ownerUserId }: { name: string; slug: string; ownerUserId: number }
7
+ ): Promise<TmOrganization> {
8
+ const result = await pool.query<TmOrganization>(
9
+ `INSERT INTO tm_organizations (name, slug, owner_user_id, settings)
10
+ VALUES ($1, $2, $3, '{}')
11
+ RETURNING *`,
12
+ [name, slug, ownerUserId]
13
+ );
14
+ return result.rows[0];
15
+ }
16
+
17
+ export async function getOrg(pool: Pool, orgId: number): Promise<TmOrganization | null> {
18
+ const result = await pool.query<TmOrganization>(
19
+ `SELECT * FROM tm_organizations WHERE id = $1 AND deleted_at IS NULL`,
20
+ [orgId]
21
+ );
22
+ return result.rows[0] ?? null;
23
+ }
24
+
25
+ export async function getOrgBySlug(pool: Pool, slug: string): Promise<TmOrganization | null> {
26
+ const result = await pool.query<TmOrganization>(
27
+ `SELECT * FROM tm_organizations WHERE slug = $1 AND deleted_at IS NULL`,
28
+ [slug]
29
+ );
30
+ return result.rows[0] ?? null;
31
+ }
32
+
33
+ export async function updateOrg(
34
+ pool: Pool,
35
+ orgId: number,
36
+ updates: { name?: string; slug?: string }
37
+ ): Promise<TmOrganization> {
38
+ const fields: string[] = [];
39
+ const values: unknown[] = [];
40
+ let idx = 1;
41
+
42
+ if (updates.name !== undefined) {
43
+ fields.push(`name = $${idx++}`);
44
+ values.push(updates.name);
45
+ }
46
+ if (updates.slug !== undefined) {
47
+ fields.push(`slug = $${idx++}`);
48
+ values.push(updates.slug);
49
+ }
50
+ if (fields.length === 0) {
51
+ const existing = await getOrg(pool, orgId);
52
+ if (!existing) throw new Error('Organization not found');
53
+ return existing;
54
+ }
55
+
56
+ fields.push(`updated_at = NOW()`);
57
+ values.push(orgId);
58
+
59
+ const result = await pool.query<TmOrganization>(
60
+ `UPDATE tm_organizations SET ${fields.join(', ')} WHERE id = $${idx} AND deleted_at IS NULL RETURNING *`,
61
+ values
62
+ );
63
+ if (result.rows.length === 0) throw new Error('Organization not found or already deleted');
64
+ return result.rows[0];
65
+ }
66
+
67
+ export async function softDeleteOrg(
68
+ pool: Pool,
69
+ orgId: number,
70
+ deletedByUserId: number
71
+ ): Promise<void> {
72
+ const result = await pool.query(
73
+ `UPDATE tm_organizations
74
+ SET deleted_at = NOW(),
75
+ delete_scheduled_for = NOW() + INTERVAL '30 days',
76
+ deleted_by_user_id = $1,
77
+ updated_at = NOW()
78
+ WHERE id = $2 AND deleted_at IS NULL`,
79
+ [deletedByUserId, orgId]
80
+ );
81
+ if (result.rowCount === 0) throw new Error('Organization not found or already deleted');
82
+ }
83
+
84
+ export async function listOrgMembers(
85
+ pool: Pool,
86
+ orgId: number,
87
+ { includeRemoved = false }: { includeRemoved?: boolean } = {}
88
+ ): Promise<TmMembership[]> {
89
+ const whereClause = includeRemoved
90
+ ? `WHERE org_id = $1`
91
+ : `WHERE org_id = $1 AND removed_at IS NULL`;
92
+
93
+ const result = await pool.query<TmMembership>(
94
+ `SELECT * FROM tm_memberships ${whereClause} ORDER BY joined_at ASC`,
95
+ [orgId]
96
+ );
97
+ return result.rows;
98
+ }
99
+
100
+ export async function getActiveMembership(
101
+ pool: Pool,
102
+ orgId: number,
103
+ userId: number
104
+ ): Promise<TmMembership | null> {
105
+ const result = await pool.query<TmMembership>(
106
+ `SELECT * FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
107
+ [orgId, userId]
108
+ );
109
+ return result.rows[0] ?? null;
110
+ }
@@ -0,0 +1,170 @@
1
+ import type { Pool } from 'pg';
2
+ import type { TmOwnershipTransfer, ServerModuleAdapter } from '../types.js';
3
+
4
+ const TRANSFER_EXPIRY_HOURS = 48;
5
+
6
+ export async function initiateTransfer(
7
+ pool: Pool,
8
+ adapter: ServerModuleAdapter,
9
+ {
10
+ orgId,
11
+ fromUserId,
12
+ toUserId,
13
+ baseUrl,
14
+ }: { orgId: number; fromUserId: number; toUserId: number; baseUrl: string }
15
+ ): Promise<TmOwnershipTransfer> {
16
+ // Validate target is an active admin (not owner)
17
+ const targetMembership = await pool.query(
18
+ `SELECT role FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
19
+ [orgId, toUserId]
20
+ );
21
+ if (targetMembership.rows.length === 0) {
22
+ throw new Error('Target user is not a member of this organization');
23
+ }
24
+ if (targetMembership.rows[0].role !== 'admin') {
25
+ throw new Error('Ownership can only be transferred to an admin member');
26
+ }
27
+
28
+ // Check no pending transfer exists
29
+ const pending = await pool.query(
30
+ `SELECT id FROM tm_ownership_transfers
31
+ WHERE org_id = $1 AND status = 'pending' AND expires_at > NOW()`,
32
+ [orgId]
33
+ );
34
+ if (pending.rows.length > 0) {
35
+ throw new Error('A pending ownership transfer already exists for this organization');
36
+ }
37
+
38
+ const expiresAt = new Date(Date.now() + TRANSFER_EXPIRY_HOURS * 60 * 60 * 1000);
39
+
40
+ const result = await pool.query<TmOwnershipTransfer>(
41
+ `INSERT INTO tm_ownership_transfers (org_id, from_user_id, to_user_id, status, expires_at)
42
+ VALUES ($1, $2, $3, 'pending', $4)
43
+ RETURNING *`,
44
+ [orgId, fromUserId, toUserId, expiresAt]
45
+ );
46
+
47
+ const transfer = result.rows[0];
48
+
49
+ // Send email to target user
50
+ const orgResult = await pool.query(`SELECT name FROM tm_organizations WHERE id = $1`, [orgId]);
51
+ const fromUser = await adapter.getUserById(fromUserId);
52
+ const toUser = await adapter.getUserById(toUserId);
53
+ const orgName = orgResult.rows[0]?.name ?? 'Unknown Organization';
54
+ const fromName = fromUser?.name ?? fromUser?.email ?? 'The current owner';
55
+
56
+ if (toUser?.email) {
57
+ try {
58
+ await adapter.sendOwnershipTransferEmail({
59
+ to: toUser.email,
60
+ orgName,
61
+ fromName,
62
+ transferUrl: `${baseUrl}/orgs/${orgId}/transfer/accept`,
63
+ });
64
+ } catch (e) {
65
+ adapter.logger.warn('[ownership] Failed to send transfer email', {
66
+ transferId: transfer.id,
67
+ error: (e as Error).message,
68
+ });
69
+ }
70
+ }
71
+
72
+ return transfer;
73
+ }
74
+
75
+ export async function acceptTransfer(
76
+ pool: Pool,
77
+ adapter: ServerModuleAdapter,
78
+ { orgId, acceptingUserId }: { orgId: number; acceptingUserId: number }
79
+ ): Promise<void> {
80
+ const client = await pool.connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+
84
+ const transferResult = await client.query<TmOwnershipTransfer>(
85
+ `SELECT * FROM tm_ownership_transfers
86
+ WHERE org_id = $1 AND status = 'pending' AND expires_at > NOW()
87
+ FOR UPDATE`,
88
+ [orgId]
89
+ );
90
+ if (transferResult.rows.length === 0) {
91
+ throw new Error('No valid pending transfer found for this organization');
92
+ }
93
+
94
+ const transfer = transferResult.rows[0];
95
+ if (transfer.to_user_id !== acceptingUserId) {
96
+ throw new Error('Only the designated recipient can accept this transfer');
97
+ }
98
+
99
+ // Atomic: demote old owner first (avoids two-owner constraint), then promote new owner
100
+ await client.query(
101
+ `UPDATE tm_memberships SET role = 'admin', updated_at = NOW()
102
+ WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
103
+ [orgId, transfer.from_user_id]
104
+ );
105
+ await client.query(
106
+ `UPDATE tm_memberships SET role = 'owner', updated_at = NOW()
107
+ WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
108
+ [orgId, transfer.to_user_id]
109
+ );
110
+
111
+ // Update denormalized owner_user_id on org
112
+ await client.query(
113
+ `UPDATE tm_organizations SET owner_user_id = $1, updated_at = NOW() WHERE id = $2`,
114
+ [transfer.to_user_id, orgId]
115
+ );
116
+
117
+ // Mark transfer as accepted
118
+ await client.query(
119
+ `UPDATE tm_ownership_transfers
120
+ SET status = 'accepted', accepted_at = NOW()
121
+ WHERE id = $1`,
122
+ [transfer.id]
123
+ );
124
+
125
+ await client.query('COMMIT');
126
+ } catch (e) {
127
+ await client.query('ROLLBACK');
128
+ throw e;
129
+ } finally {
130
+ client.release();
131
+ }
132
+ }
133
+
134
+ export async function cancelTransfer(
135
+ pool: Pool,
136
+ { orgId, cancelledByUserId }: { orgId: number; cancelledByUserId: number }
137
+ ): Promise<void> {
138
+ const result = await pool.query(
139
+ `UPDATE tm_ownership_transfers
140
+ SET status = 'cancelled', cancelled_at = NOW(), cancelled_by_user_id = $1
141
+ WHERE org_id = $2 AND status = 'pending'`,
142
+ [cancelledByUserId, orgId]
143
+ );
144
+ if ((result.rowCount ?? 0) === 0) {
145
+ throw new Error('No pending transfer found to cancel');
146
+ }
147
+ }
148
+
149
+ export async function getPendingTransfer(
150
+ pool: Pool,
151
+ orgId: number
152
+ ): Promise<TmOwnershipTransfer | null> {
153
+ const result = await pool.query<TmOwnershipTransfer>(
154
+ `SELECT * FROM tm_ownership_transfers
155
+ WHERE org_id = $1 AND status = 'pending' AND expires_at > NOW()
156
+ ORDER BY initiated_at DESC
157
+ LIMIT 1`,
158
+ [orgId]
159
+ );
160
+ return result.rows[0] ?? null;
161
+ }
162
+
163
+ export async function expireTransfers(pool: Pool): Promise<number> {
164
+ const result = await pool.query(
165
+ `UPDATE tm_ownership_transfers
166
+ SET status = 'expired'
167
+ WHERE status = 'pending' AND expires_at <= NOW()`
168
+ );
169
+ return result.rowCount ?? 0;
170
+ }
@@ -0,0 +1,94 @@
1
+ import type { Pool } from 'pg';
2
+ import type { TmPasswordResetRequest, ServerModuleAdapter } from '../types.js';
3
+ import { generateToken, sha256 } from '../crypto.js';
4
+ import { writeAuditEvent } from './audit.service.js';
5
+
6
+ const RESET_EXPIRY_HOURS = 2;
7
+ const RATE_LIMIT_MAX = 3;
8
+ const RATE_LIMIT_WINDOW_HOURS = 1;
9
+
10
+ export async function requestPasswordReset(
11
+ pool: Pool,
12
+ adapter: ServerModuleAdapter,
13
+ {
14
+ email,
15
+ baseUrl,
16
+ triggeredBySuperAdmin: _triggeredBySuperAdmin = false,
17
+ }: { email: string; baseUrl: string; triggeredBySuperAdmin?: boolean }
18
+ ): Promise<void> {
19
+ // Always return success — do not leak whether email exists
20
+ const user = await adapter.findUserByEmail(email);
21
+ if (!user) return;
22
+
23
+ // Rate limit: 3 requests per email per hour
24
+ const rateCheck = await pool.query(
25
+ `SELECT COUNT(*) FROM tm_password_reset_requests
26
+ WHERE user_id = $1 AND created_at > NOW() - INTERVAL '${RATE_LIMIT_WINDOW_HOURS} hours'
27
+ AND used_at IS NULL`,
28
+ [user.id]
29
+ );
30
+ const count = parseInt(rateCheck.rows[0].count, 10);
31
+ if (count >= RATE_LIMIT_MAX) {
32
+ // Silently return — don't reveal rate limit to potential attackers
33
+ return;
34
+ }
35
+
36
+ const token = generateToken(32);
37
+ const tokenHash = sha256(token);
38
+ const expiresAt = new Date(Date.now() + RESET_EXPIRY_HOURS * 60 * 60 * 1000);
39
+
40
+ await pool.query(
41
+ `INSERT INTO tm_password_reset_requests (user_id, token_hash, expires_at)
42
+ VALUES ($1, $2, $3)`,
43
+ [user.id, tokenHash, expiresAt]
44
+ );
45
+
46
+ const resetUrl = `${baseUrl}/reset-password?token=${token}`;
47
+
48
+ await adapter.sendPasswordResetEmail({ to: email, resetUrl });
49
+ }
50
+
51
+ export async function resetPassword(
52
+ pool: Pool,
53
+ adapter: ServerModuleAdapter,
54
+ { token, newPassword }: { token: string; newPassword: string }
55
+ ): Promise<void> {
56
+ if (!newPassword || newPassword.length < 8) {
57
+ throw new Error('Password must be at least 8 characters');
58
+ }
59
+
60
+ const tokenHash = sha256(token);
61
+
62
+ const result = await pool.query<TmPasswordResetRequest>(
63
+ `SELECT * FROM tm_password_reset_requests
64
+ WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()`,
65
+ [tokenHash]
66
+ );
67
+ if (result.rows.length === 0) {
68
+ throw new Error('Invalid or expired password reset token');
69
+ }
70
+
71
+ const request = result.rows[0];
72
+ const hashedPassword = await adapter.hashPassword(newPassword);
73
+
74
+ await adapter.setUserPassword(request.user_id, hashedPassword);
75
+
76
+ // Mark token as used
77
+ await pool.query(
78
+ `UPDATE tm_password_reset_requests SET used_at = NOW() WHERE id = $1`,
79
+ [request.id]
80
+ );
81
+
82
+ // Invalidate all sessions for security
83
+ await adapter.invalidateAllUserSessions(request.user_id);
84
+
85
+ await writeAuditEvent({
86
+ pool,
87
+ orgId: null,
88
+ actorUserId: request.user_id,
89
+ action: 'user.password_reset',
90
+ targetType: 'user',
91
+ targetId: request.user_id,
92
+ });
93
+ }
94
+
@@ -0,0 +1,321 @@
1
+ import type { Pool } from 'pg';
2
+ import type { TmOrganization, TmMembership, OrgRole, ServerModuleAdapter } from '../types.js';
3
+ import { writeAuditEvent } from './audit.service.js';
4
+ import { requestPasswordReset } from './password-reset.service.js';
5
+
6
+ export async function listAllOrgs(pool: Pool): Promise<TmOrganization[]> {
7
+ const result = await pool.query<TmOrganization>(
8
+ `SELECT * FROM tm_organizations ORDER BY created_at DESC`
9
+ );
10
+ return result.rows;
11
+ }
12
+
13
+ export async function getOrgForAdmin(
14
+ pool: Pool,
15
+ orgId: number
16
+ ): Promise<TmOrganization & { memberCount: number }> {
17
+ const result = await pool.query(
18
+ `SELECT o.*,
19
+ COUNT(m.id) FILTER (WHERE m.removed_at IS NULL) AS member_count
20
+ FROM tm_organizations o
21
+ LEFT JOIN tm_memberships m ON m.org_id = o.id
22
+ WHERE o.id = $1
23
+ GROUP BY o.id`,
24
+ [orgId]
25
+ );
26
+ if (result.rows.length === 0) throw new Error('Organization not found');
27
+ const row = result.rows[0];
28
+ return { ...row, memberCount: parseInt(row.member_count, 10) };
29
+ }
30
+
31
+ export async function getUserForAdmin(
32
+ pool: Pool,
33
+ adapter: ServerModuleAdapter,
34
+ userId: number
35
+ ): Promise<{ id: number; email: string; name?: string; memberships: TmMembership[] }> {
36
+ const user = await adapter.getUserById(userId);
37
+ if (!user) throw new Error('User not found');
38
+
39
+ const memberships = await pool.query<TmMembership>(
40
+ `SELECT * FROM tm_memberships WHERE user_id = $1 ORDER BY joined_at DESC`,
41
+ [userId]
42
+ );
43
+ return { ...user, memberships: memberships.rows };
44
+ }
45
+
46
+ export async function restoreOrg(
47
+ pool: Pool,
48
+ {
49
+ orgId,
50
+ superAdminUserId,
51
+ reason,
52
+ }: { orgId: number; superAdminUserId: number; reason: string }
53
+ ): Promise<void> {
54
+ const result = await pool.query(
55
+ `UPDATE tm_organizations
56
+ SET deleted_at = NULL, delete_scheduled_for = NULL, deleted_by_user_id = NULL, updated_at = NOW()
57
+ WHERE id = $1 AND deleted_at IS NOT NULL`,
58
+ [orgId]
59
+ );
60
+ if ((result.rowCount ?? 0) === 0) throw new Error('Organization not found or not deleted');
61
+
62
+ await writeAuditEvent({
63
+ pool,
64
+ orgId,
65
+ actorUserId: superAdminUserId,
66
+ actorType: 'super_admin',
67
+ action: 'org.restored',
68
+ targetType: 'org',
69
+ targetId: orgId,
70
+ reason,
71
+ });
72
+ }
73
+
74
+ export async function appointOwner(
75
+ pool: Pool,
76
+ {
77
+ orgId,
78
+ targetUserId,
79
+ superAdminUserId,
80
+ reason,
81
+ }: { orgId: number; targetUserId: number; superAdminUserId: number; reason: string }
82
+ ): Promise<void> {
83
+ const client = await pool.connect();
84
+ try {
85
+ await client.query('BEGIN');
86
+
87
+ // Demote current owner to admin
88
+ await client.query(
89
+ `UPDATE tm_memberships SET role = 'admin', updated_at = NOW()
90
+ WHERE org_id = $1 AND role = 'owner' AND removed_at IS NULL`,
91
+ [orgId]
92
+ );
93
+
94
+ // Upsert new owner
95
+ await client.query(
96
+ `INSERT INTO tm_memberships (org_id, user_id, role, joined_at)
97
+ VALUES ($1, $2, 'owner', NOW())
98
+ ON CONFLICT (org_id, user_id) WHERE removed_at IS NULL
99
+ DO UPDATE SET role = 'owner', removed_at = NULL, updated_at = NOW()`,
100
+ [orgId, targetUserId]
101
+ );
102
+
103
+ // Update denormalized owner field
104
+ await client.query(
105
+ `UPDATE tm_organizations SET owner_user_id = $1, updated_at = NOW() WHERE id = $2`,
106
+ [targetUserId, orgId]
107
+ );
108
+
109
+ await client.query('COMMIT');
110
+ } catch (e) {
111
+ await client.query('ROLLBACK');
112
+ throw e;
113
+ } finally {
114
+ client.release();
115
+ }
116
+
117
+ await writeAuditEvent({
118
+ pool,
119
+ orgId,
120
+ actorUserId: superAdminUserId,
121
+ actorType: 'super_admin',
122
+ action: 'org.owner_appointed',
123
+ targetType: 'user',
124
+ targetId: targetUserId,
125
+ reason,
126
+ });
127
+ }
128
+
129
+ export async function hardDeleteOrg(
130
+ pool: Pool,
131
+ {
132
+ orgId,
133
+ superAdminUserId,
134
+ legalBasis,
135
+ }: { orgId: number; superAdminUserId: number; legalBasis: string }
136
+ ): Promise<void> {
137
+ if (!legalBasis || legalBasis.trim().length < 10) {
138
+ throw new Error('A legal basis of at least 10 characters is required for hard delete');
139
+ }
140
+
141
+ await writeAuditEvent({
142
+ pool,
143
+ orgId,
144
+ actorUserId: superAdminUserId,
145
+ actorType: 'super_admin',
146
+ action: 'org.hard_deleted',
147
+ targetType: 'org',
148
+ targetId: orgId,
149
+ reason: legalBasis,
150
+ });
151
+
152
+ // Hard delete — cascades to memberships, invitations, etc.
153
+ await pool.query(`DELETE FROM tm_organizations WHERE id = $1`, [orgId]);
154
+ }
155
+
156
+ export async function addMemberAdmin(
157
+ pool: Pool,
158
+ {
159
+ orgId,
160
+ userId,
161
+ role,
162
+ superAdminUserId,
163
+ reason,
164
+ }: { orgId: number; userId: number; role: OrgRole; superAdminUserId: number; reason: string }
165
+ ): Promise<void> {
166
+ await pool.query(
167
+ `INSERT INTO tm_memberships (org_id, user_id, role, joined_at)
168
+ VALUES ($1, $2, $3, NOW())
169
+ ON CONFLICT (org_id, user_id)
170
+ DO UPDATE SET role = EXCLUDED.role, removed_at = NULL, removed_by_user_id = NULL,
171
+ removal_reason = NULL, updated_at = NOW()`,
172
+ [orgId, userId, role]
173
+ );
174
+
175
+ await writeAuditEvent({
176
+ pool,
177
+ orgId,
178
+ actorUserId: superAdminUserId,
179
+ actorType: 'super_admin',
180
+ action: 'org.member_added_admin',
181
+ targetType: 'user',
182
+ targetId: userId,
183
+ after: { role },
184
+ reason,
185
+ });
186
+ }
187
+
188
+ export async function removeMemberAdmin(
189
+ pool: Pool,
190
+ {
191
+ orgId,
192
+ userId,
193
+ superAdminUserId,
194
+ reason,
195
+ }: { orgId: number; userId: number; superAdminUserId: number; reason: string }
196
+ ): Promise<void> {
197
+ await pool.query(
198
+ `UPDATE tm_memberships
199
+ SET removed_at = NOW(), removed_by_user_id = $1, removal_reason = $2, updated_at = NOW()
200
+ WHERE org_id = $3 AND user_id = $4 AND removed_at IS NULL`,
201
+ [superAdminUserId, reason, orgId, userId]
202
+ );
203
+
204
+ await writeAuditEvent({
205
+ pool,
206
+ orgId,
207
+ actorUserId: superAdminUserId,
208
+ actorType: 'super_admin',
209
+ action: 'org.member_removed_admin',
210
+ targetType: 'user',
211
+ targetId: userId,
212
+ reason,
213
+ });
214
+ }
215
+
216
+ export async function lockUser(
217
+ pool: Pool,
218
+ adapter: ServerModuleAdapter,
219
+ {
220
+ userId,
221
+ superAdminUserId,
222
+ reason,
223
+ }: { userId: number; superAdminUserId: number; reason: string }
224
+ ): Promise<void> {
225
+ await pool.query(
226
+ `INSERT INTO tm_user_locks (user_id, locked_by, reason, locked_at)
227
+ VALUES ($1, $2, $3, NOW())
228
+ ON CONFLICT (user_id) DO UPDATE SET locked_by = EXCLUDED.locked_by,
229
+ reason = EXCLUDED.reason, locked_at = EXCLUDED.locked_at, unlocked_at = NULL`,
230
+ [userId, superAdminUserId, reason]
231
+ );
232
+
233
+ await adapter.invalidateAllUserSessions(userId);
234
+
235
+ await writeAuditEvent({
236
+ pool,
237
+ orgId: null,
238
+ actorUserId: superAdminUserId,
239
+ actorType: 'super_admin',
240
+ action: 'user.locked',
241
+ targetType: 'user',
242
+ targetId: userId,
243
+ reason,
244
+ });
245
+ }
246
+
247
+ export async function unlockUser(
248
+ pool: Pool,
249
+ adapter: ServerModuleAdapter,
250
+ {
251
+ userId,
252
+ superAdminUserId,
253
+ reason,
254
+ }: { userId: number; superAdminUserId: number; reason: string }
255
+ ): Promise<void> {
256
+ await pool.query(
257
+ `UPDATE tm_user_locks SET unlocked_at = NOW() WHERE user_id = $1 AND unlocked_at IS NULL`,
258
+ [userId]
259
+ );
260
+
261
+ await writeAuditEvent({
262
+ pool,
263
+ orgId: null,
264
+ actorUserId: superAdminUserId,
265
+ actorType: 'super_admin',
266
+ action: 'user.unlocked',
267
+ targetType: 'user',
268
+ targetId: userId,
269
+ reason,
270
+ });
271
+ }
272
+
273
+ export async function triggerPasswordReset(
274
+ pool: Pool,
275
+ adapter: ServerModuleAdapter,
276
+ {
277
+ userId,
278
+ superAdminUserId,
279
+ reason,
280
+ baseUrl,
281
+ }: { userId: number; superAdminUserId: number; reason: string; baseUrl: string }
282
+ ): Promise<void> {
283
+ const user = await adapter.getUserById(userId);
284
+ if (!user) throw new Error('User not found');
285
+
286
+ await requestPasswordReset(pool, adapter, {
287
+ email: user.email,
288
+ baseUrl,
289
+ triggeredBySuperAdmin: true,
290
+ });
291
+
292
+ await writeAuditEvent({
293
+ pool,
294
+ orgId: null,
295
+ actorUserId: superAdminUserId,
296
+ actorType: 'super_admin',
297
+ action: 'user.password_reset_triggered',
298
+ targetType: 'user',
299
+ targetId: userId,
300
+ reason,
301
+ });
302
+ }
303
+
304
+ export async function seedSuperAdmin(
305
+ pool: Pool,
306
+ adapter: ServerModuleAdapter,
307
+ email: string
308
+ ): Promise<void> {
309
+ const user = await adapter.findUserByEmail(email);
310
+ if (!user) {
311
+ // User not found — skip silently (idempotent)
312
+ return;
313
+ }
314
+
315
+ await pool.query(
316
+ `INSERT INTO tm_super_admins (user_id, granted_at)
317
+ VALUES ($1, NOW())
318
+ ON CONFLICT (user_id) DO NOTHING`,
319
+ [user.id]
320
+ );
321
+ }
File without changes