@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.
- package/.eslintrc.cjs +18 -0
- package/CHANGELOG.md +159 -0
- package/LICENSE +6 -0
- package/README.md +97 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/server/crypto.d.ts +6 -0
- package/dist/server/crypto.d.ts.map +1 -0
- package/dist/server/crypto.js +42 -0
- package/dist/server/crypto.js.map +1 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +114 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware/require-membership.d.ts +10 -0
- package/dist/server/middleware/require-membership.d.ts.map +1 -0
- package/dist/server/middleware/require-membership.js +33 -0
- package/dist/server/middleware/require-membership.js.map +1 -0
- package/dist/server/middleware/require-role.d.ts +4 -0
- package/dist/server/middleware/require-role.d.ts.map +1 -0
- package/dist/server/middleware/require-role.js +16 -0
- package/dist/server/middleware/require-role.js.map +1 -0
- package/dist/server/middleware/require-super-admin.d.ts +5 -0
- package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
- package/dist/server/middleware/require-super-admin.js +27 -0
- package/dist/server/middleware/require-super-admin.js.map +1 -0
- package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
- package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/dist/server/routes/admin.routes.d.ts +5 -0
- package/dist/server/routes/admin.routes.d.ts.map +1 -0
- package/dist/server/routes/admin.routes.js +262 -0
- package/dist/server/routes/admin.routes.js.map +1 -0
- package/dist/server/routes/audit.routes.d.ts +5 -0
- package/dist/server/routes/audit.routes.d.ts.map +1 -0
- package/dist/server/routes/audit.routes.js +70 -0
- package/dist/server/routes/audit.routes.js.map +1 -0
- package/dist/server/routes/health.routes.d.ts +8 -0
- package/dist/server/routes/health.routes.d.ts.map +1 -0
- package/dist/server/routes/health.routes.js +39 -0
- package/dist/server/routes/health.routes.js.map +1 -0
- package/dist/server/routes/invitations.routes.d.ts +5 -0
- package/dist/server/routes/invitations.routes.d.ts.map +1 -0
- package/dist/server/routes/invitations.routes.js +232 -0
- package/dist/server/routes/invitations.routes.js.map +1 -0
- package/dist/server/routes/me.routes.d.ts +5 -0
- package/dist/server/routes/me.routes.d.ts.map +1 -0
- package/dist/server/routes/me.routes.js +188 -0
- package/dist/server/routes/me.routes.js.map +1 -0
- package/dist/server/routes/orgs.routes.d.ts +5 -0
- package/dist/server/routes/orgs.routes.d.ts.map +1 -0
- package/dist/server/routes/orgs.routes.js +371 -0
- package/dist/server/routes/orgs.routes.js.map +1 -0
- package/dist/server/routes/transfer.routes.d.ts +5 -0
- package/dist/server/routes/transfer.routes.d.ts.map +1 -0
- package/dist/server/routes/transfer.routes.js +108 -0
- package/dist/server/routes/transfer.routes.js.map +1 -0
- package/dist/server/services/audit.service.d.ts +20 -0
- package/dist/server/services/audit.service.d.ts.map +1 -0
- package/dist/server/services/audit.service.js +23 -0
- package/dist/server/services/audit.service.js.map +1 -0
- package/dist/server/services/email-change.service.d.ts +16 -0
- package/dist/server/services/email-change.service.d.ts.map +1 -0
- package/dist/server/services/email-change.service.js +107 -0
- package/dist/server/services/email-change.service.js.map +1 -0
- package/dist/server/services/invitations.service.d.ts +41 -0
- package/dist/server/services/invitations.service.d.ts.map +1 -0
- package/dist/server/services/invitations.service.js +214 -0
- package/dist/server/services/invitations.service.js.map +1 -0
- package/dist/server/services/memberships.service.d.ts +27 -0
- package/dist/server/services/memberships.service.d.ts.map +1 -0
- package/dist/server/services/memberships.service.js +69 -0
- package/dist/server/services/memberships.service.js.map +1 -0
- package/dist/server/services/organizations.service.d.ts +19 -0
- package/dist/server/services/organizations.service.d.ts.map +1 -0
- package/dist/server/services/organizations.service.js +61 -0
- package/dist/server/services/organizations.service.js.map +1 -0
- package/dist/server/services/ownership.service.d.ts +19 -0
- package/dist/server/services/ownership.service.d.ts.map +1 -0
- package/dist/server/services/ownership.service.js +102 -0
- package/dist/server/services/ownership.service.js.map +1 -0
- package/dist/server/services/password-reset.service.d.ts +12 -0
- package/dist/server/services/password-reset.service.d.ts.map +1 -0
- package/dist/server/services/password-reset.service.js +54 -0
- package/dist/server/services/password-reset.service.js.map +1 -0
- package/dist/server/services/super-admin.service.d.ts +59 -0
- package/dist/server/services/super-admin.service.d.ts.map +1 -0
- package/dist/server/services/super-admin.service.js +187 -0
- package/dist/server/services/super-admin.service.js.map +1 -0
- package/dist/server/types.d.ts +186 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +6 -0
- package/dist/server/types.js.map +1 -0
- package/dist/shared/types.d.ts +23 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +6 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +56 -0
- package/src/client/api.ts +314 -0
- package/src/client/components/AuditEventRow.tsx +59 -0
- package/src/client/components/CascadePreview.tsx +36 -0
- package/src/client/components/DangerZoneCard.tsx +103 -0
- package/src/client/components/InvitationCodeDisplay.tsx +48 -0
- package/src/client/components/InviteForm.tsx +77 -0
- package/src/client/components/MemberRow.tsx +69 -0
- package/src/client/components/PendingTransferBanner.tsx +98 -0
- package/src/client/components/PlaceholderCard.tsx +26 -0
- package/src/client/components/RoleBadge.tsx +26 -0
- package/src/client/components/RoleSelect.tsx +35 -0
- package/src/client/hooks/.gitkeep +0 -0
- package/src/client/hooks/useCurrentMembership.ts +24 -0
- package/src/client/hooks/useMembers.ts +24 -0
- package/src/client/hooks/usePendingInvitations.ts +24 -0
- package/src/client/hooks/usePendingTransfer.ts +27 -0
- package/src/client/index.ts +80 -0
- package/src/client/pages/AuditLogPage.tsx +164 -0
- package/src/client/pages/EmailChangePage.tsx +144 -0
- package/src/client/pages/InvitationAcceptPage.tsx +163 -0
- package/src/client/pages/InvitationCodePage.tsx +108 -0
- package/src/client/pages/MembersPage.tsx +290 -0
- package/src/client/pages/OrgSettingsPage.tsx +185 -0
- package/src/client/pages/OwnershipTransferPage.tsx +163 -0
- package/src/client/pages/PasswordResetPage.tsx +104 -0
- package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
- package/src/client/pages/PlaceholderPage.tsx +20 -0
- package/src/client/pages/SuperAdminDashboard.tsx +401 -0
- package/src/client/types.ts +78 -0
- package/src/index.ts +24 -0
- package/src/server/crypto.ts +47 -0
- package/src/server/index.ts +167 -0
- package/src/server/middleware/require-membership.ts +48 -0
- package/src/server/middleware/require-role.ts +19 -0
- package/src/server/middleware/require-super-admin.ts +32 -0
- package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/src/server/migrations/0011_seed_super_admin.sql +15 -0
- package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/src/server/routes/admin.routes.ts +208 -0
- package/src/server/routes/audit.routes.ts +93 -0
- package/src/server/routes/health.routes.ts +46 -0
- package/src/server/routes/invitations.routes.ts +252 -0
- package/src/server/routes/me.routes.ts +143 -0
- package/src/server/routes/orgs.routes.ts +428 -0
- package/src/server/routes/transfer.routes.ts +110 -0
- package/src/server/services/.gitkeep +0 -0
- package/src/server/services/audit.service.ts +49 -0
- package/src/server/services/email-change.service.ts +178 -0
- package/src/server/services/invitations.service.ts +316 -0
- package/src/server/services/memberships.service.ts +129 -0
- package/src/server/services/organizations.service.ts +110 -0
- package/src/server/services/ownership.service.ts +170 -0
- package/src/server/services/password-reset.service.ts +94 -0
- package/src/server/services/super-admin.service.ts +321 -0
- package/src/server/sql/.gitkeep +0 -0
- package/src/server/types.ts +145 -0
- package/src/shared/types.ts +24 -0
- package/tests/integration/audit-fires.test.ts +288 -0
- package/tests/integration/cascade-preview.test.ts +157 -0
- package/tests/integration/email-change.test.ts +190 -0
- package/tests/integration/feature-flags.test.ts +213 -0
- package/tests/integration/invitations-code.test.ts +218 -0
- package/tests/integration/invitations-expiry.test.ts +216 -0
- package/tests/integration/invitations-resend.test.ts +241 -0
- package/tests/integration/invitations-revoke.test.ts +226 -0
- package/tests/integration/invitations-switch-org.test.ts +156 -0
- package/tests/integration/invitations-token.test.ts +221 -0
- package/tests/integration/migrations.test.ts +119 -0
- package/tests/integration/only-owner-protections.test.ts +130 -0
- package/tests/integration/org-lifecycle.test.ts +169 -0
- package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
- package/tests/integration/ownership-transfer-expire.test.ts +171 -0
- package/tests/integration/ownership-transfer-happy.test.ts +184 -0
- package/tests/integration/ownership-transfer-locks.test.ts +146 -0
- package/tests/integration/password-reset.test.ts +200 -0
- package/tests/integration/super-admin-actions.test.ts +180 -0
- package/tests/integration/super-admin-restrictions.test.ts +209 -0
- package/tests/setup/global-setup.ts +20 -0
- package/tests/unit/adapter-shape.test.ts +330 -0
- package/tests/unit/role-permissions.test.ts +236 -0
- package/tests/unit/validation.test.ts +304 -0
- package/tsconfig.client.json +13 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { Pool } from 'pg';
|
|
3
|
+
import type { ServerModuleAdapter, TeamManagementFeatureFlags } from '../types.js';
|
|
4
|
+
import { requestEmailChange, verifyEmailChange, cancelEmailChange } from '../services/email-change.service.js';
|
|
5
|
+
import { requestPasswordReset, resetPassword } from '../services/password-reset.service.js';
|
|
6
|
+
import { getActiveMembership } from '../services/organizations.service.js';
|
|
7
|
+
import { sha256 } from '../crypto.js';
|
|
8
|
+
|
|
9
|
+
export function createMeRouter(
|
|
10
|
+
pool: Pool,
|
|
11
|
+
adapter: ServerModuleAdapter,
|
|
12
|
+
flags: TeamManagementFeatureFlags,
|
|
13
|
+
baseUrl: string
|
|
14
|
+
): Router {
|
|
15
|
+
const router = Router();
|
|
16
|
+
|
|
17
|
+
router.get('/membership', async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const userId = await adapter.getCurrentUserId(req);
|
|
20
|
+
if (!userId) { res.status(401).json({ error: 'Authentication required' }); return; }
|
|
21
|
+
const orgId = await adapter.getOrganizationIdForUser(userId);
|
|
22
|
+
if (!orgId) { res.status(404).json({ error: 'No organization membership found' }); return; }
|
|
23
|
+
const membership = await getActiveMembership(pool, orgId, userId);
|
|
24
|
+
res.json({ membership });
|
|
25
|
+
} catch (e) {
|
|
26
|
+
adapter.logger.error('[me] GET /membership', { error: (e as Error).message });
|
|
27
|
+
res.status(500).json({ error: 'Failed to fetch membership' });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.post('/email-change', async (req, res) => {
|
|
32
|
+
if (!flags.enableEmailChange) { res.status(501).json({ error: 'Email change is not enabled' }); return; }
|
|
33
|
+
try {
|
|
34
|
+
const userId = await adapter.getCurrentUserId(req);
|
|
35
|
+
if (!userId) { res.status(401).json({ error: 'Authentication required' }); return; }
|
|
36
|
+
const user = await adapter.getUserById(userId);
|
|
37
|
+
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
|
|
38
|
+
const { newEmail } = req.body as { newEmail?: string };
|
|
39
|
+
if (!newEmail) { res.status(400).json({ error: 'newEmail is required' }); return; }
|
|
40
|
+
await requestEmailChange(pool, adapter, { userId, currentEmail: user.email, newEmail, baseUrl });
|
|
41
|
+
res.json({ message: 'Verification email sent to your new address' });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const msg = (e as Error).message;
|
|
44
|
+
adapter.logger.error('[me] POST /email-change', { error: msg });
|
|
45
|
+
if (msg.includes('Too many')) {
|
|
46
|
+
res.status(429).json({ error: msg });
|
|
47
|
+
} else if (msg.includes('already in use')) {
|
|
48
|
+
res.status(422).json({ error: msg });
|
|
49
|
+
} else {
|
|
50
|
+
res.status(500).json({ error: 'Failed to request email change' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
router.get('/email-change/verify', async (req, res) => {
|
|
56
|
+
if (!flags.enableEmailChange) { res.status(501).json({ error: 'Email change is not enabled' }); return; }
|
|
57
|
+
const token = req.query.token as string;
|
|
58
|
+
if (!token) { res.status(400).json({ error: 'token query parameter is required' }); return; }
|
|
59
|
+
try {
|
|
60
|
+
// Token-based verification — no authentication required; token is self-authenticating
|
|
61
|
+
const userId = await adapter.getCurrentUserId(req);
|
|
62
|
+
await verifyEmailChange(pool, adapter, { token, userId: userId ?? null });
|
|
63
|
+
res.json({ message: 'Email address updated successfully' });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
const msg = (e as Error).message;
|
|
66
|
+
adapter.logger.error('[me] GET /email-change/verify', { error: msg });
|
|
67
|
+
if (msg.includes('Invalid') || msg.includes('expired')) {
|
|
68
|
+
res.status(404).json({ error: msg });
|
|
69
|
+
} else {
|
|
70
|
+
res.status(500).json({ error: 'Failed to verify email change' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.get('/email-change/cancel', async (req, res) => {
|
|
76
|
+
if (!flags.enableEmailChange) { res.status(501).json({ error: 'Email change is not enabled' }); return; }
|
|
77
|
+
const token = req.query.token as string;
|
|
78
|
+
if (!token) { res.status(400).json({ error: 'token query parameter is required' }); return; }
|
|
79
|
+
try {
|
|
80
|
+
await cancelEmailChange(pool, adapter, { token });
|
|
81
|
+
res.json({ message: 'Email change cancelled. Your sessions have been invalidated for security.' });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const msg = (e as Error).message;
|
|
84
|
+
adapter.logger.error('[me] GET /email-change/cancel', { error: msg });
|
|
85
|
+
if (msg.includes('Invalid') || msg.includes('expired')) {
|
|
86
|
+
res.status(404).json({ error: msg });
|
|
87
|
+
} else {
|
|
88
|
+
res.status(500).json({ error: 'Failed to cancel email change' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.post('/password-reset/request', async (req, res) => {
|
|
94
|
+
if (!flags.enablePasswordReset) { res.status(501).json({ error: 'Password reset is not enabled' }); return; }
|
|
95
|
+
const { email } = req.body as { email?: string };
|
|
96
|
+
if (!email) { res.status(400).json({ error: 'email is required' }); return; }
|
|
97
|
+
try {
|
|
98
|
+
await requestPasswordReset(pool, adapter, { email, baseUrl });
|
|
99
|
+
res.json({ message: 'If that email exists, a reset link has been sent' });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
adapter.logger.error('[me] POST /password-reset/request', { error: (e as Error).message });
|
|
102
|
+
res.json({ message: 'If that email exists, a reset link has been sent' });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
router.get('/password-reset', async (req, res) => {
|
|
107
|
+
if (!flags.enablePasswordReset) { res.status(501).json({ error: 'Password reset is not enabled' }); return; }
|
|
108
|
+
const token = req.query.token as string;
|
|
109
|
+
if (!token) { res.status(400).json({ error: 'token query parameter is required' }); return; }
|
|
110
|
+
try {
|
|
111
|
+
const tokenHash = sha256(token);
|
|
112
|
+
const result = await pool.query(
|
|
113
|
+
`SELECT id FROM tm_password_reset_requests WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()`,
|
|
114
|
+
[tokenHash]
|
|
115
|
+
);
|
|
116
|
+
if (result.rows.length === 0) { res.status(404).json({ error: 'Invalid or expired password reset token' }); return; }
|
|
117
|
+
res.json({ valid: true });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
adapter.logger.error('[me] GET /password-reset', { error: (e as Error).message });
|
|
120
|
+
res.status(500).json({ error: 'Failed to validate token' });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.post('/password-reset', async (req, res) => {
|
|
125
|
+
if (!flags.enablePasswordReset) { res.status(501).json({ error: 'Password reset is not enabled' }); return; }
|
|
126
|
+
const { token, newPassword } = req.body as { token?: string; newPassword?: string };
|
|
127
|
+
if (!token || !newPassword) { res.status(400).json({ error: 'token and newPassword are required' }); return; }
|
|
128
|
+
try {
|
|
129
|
+
await resetPassword(pool, adapter, { token, newPassword });
|
|
130
|
+
res.json({ message: 'Password updated successfully. Please log in again.' });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const msg = (e as Error).message;
|
|
133
|
+
adapter.logger.error('[me] POST /password-reset', { error: msg });
|
|
134
|
+
if (msg.includes('Invalid') || msg.includes('expired') || msg.includes('8 characters')) {
|
|
135
|
+
res.status(422).json({ error: msg });
|
|
136
|
+
} else {
|
|
137
|
+
res.status(500).json({ error: 'Failed to reset password' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return router;
|
|
143
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
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 { getOrg, updateOrg, softDeleteOrg, listOrgMembers } from '../services/organizations.service.js';
|
|
7
|
+
import { removeMember, changeRole, validateRoleChange } from '../services/memberships.service.js';
|
|
8
|
+
import { getPendingTransfer } from '../services/ownership.service.js';
|
|
9
|
+
import { writeAuditEvent, getClientIp } from '../services/audit.service.js';
|
|
10
|
+
|
|
11
|
+
export function createOrgsRouter(
|
|
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
|
+
// POST /orgs — create org (any authenticated user)
|
|
20
|
+
router.post('/', async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const userId = await adapter.getCurrentUserId(req as import('express').Request);
|
|
23
|
+
if (!userId) {
|
|
24
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const { name, slug, settings } = req.body as { name?: string; slug?: string; settings?: Record<string, unknown> };
|
|
28
|
+
if (!name || !slug) {
|
|
29
|
+
res.status(400).json({ error: 'name and slug are required' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await pool.query(
|
|
34
|
+
`INSERT INTO tm_organizations (name, slug, owner_user_id, settings)
|
|
35
|
+
VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
36
|
+
[name, slug, userId, JSON.stringify(settings ?? {})]
|
|
37
|
+
);
|
|
38
|
+
const org = result.rows[0];
|
|
39
|
+
|
|
40
|
+
await pool.query(
|
|
41
|
+
`INSERT INTO tm_memberships (org_id, user_id, role, joined_at)
|
|
42
|
+
VALUES ($1, $2, 'owner', NOW())`,
|
|
43
|
+
[org.id, userId]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (flags.enableAuditLog) {
|
|
47
|
+
await writeAuditEvent({
|
|
48
|
+
pool,
|
|
49
|
+
orgId: org.id,
|
|
50
|
+
actorUserId: userId,
|
|
51
|
+
action: 'org.created',
|
|
52
|
+
targetType: 'org',
|
|
53
|
+
targetId: org.id,
|
|
54
|
+
after: { name, slug },
|
|
55
|
+
ip: getClientIp(req),
|
|
56
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
res.status(201).json({ org });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
const msg = (e as Error).message;
|
|
63
|
+
adapter.logger.error('[orgs] POST /', { error: msg });
|
|
64
|
+
if (msg.includes('unique') || msg.includes('duplicate') || msg.includes('already exists')) {
|
|
65
|
+
res.status(409).json({ error: 'Organization with that slug already exists' });
|
|
66
|
+
} else {
|
|
67
|
+
res.status(500).json({ error: 'Failed to create organization' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// GET /orgs/:orgId — org info (member+)
|
|
73
|
+
router.get('/:orgId', authMiddleware, async (req, res) => {
|
|
74
|
+
const { orgId } = req as AuthenticatedRequest;
|
|
75
|
+
try {
|
|
76
|
+
const org = await getOrg(pool, orgId);
|
|
77
|
+
if (!org) {
|
|
78
|
+
res.status(404).json({ error: 'Organization not found' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.json({ org });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
adapter.logger.error('[orgs] GET /:orgId', { error: (e as Error).message });
|
|
84
|
+
res.status(500).json({ error: 'Failed to fetch organization' });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// PATCH /orgs/:orgId — update name/slug/settings (admin+)
|
|
89
|
+
router.patch('/:orgId', authMiddleware, requireRole('admin'), async (req, res) => {
|
|
90
|
+
const { orgId, userId } = req as AuthenticatedRequest;
|
|
91
|
+
const { name, slug } = req.body as { name?: string; slug?: string };
|
|
92
|
+
try {
|
|
93
|
+
const before = await getOrg(pool, orgId);
|
|
94
|
+
const updated = await updateOrg(pool, orgId, { name, slug });
|
|
95
|
+
|
|
96
|
+
if (flags.enableAuditLog) {
|
|
97
|
+
await writeAuditEvent({
|
|
98
|
+
pool,
|
|
99
|
+
orgId,
|
|
100
|
+
actorUserId: userId,
|
|
101
|
+
action: 'org.settings.updated',
|
|
102
|
+
targetType: 'org',
|
|
103
|
+
targetId: orgId,
|
|
104
|
+
before: { name: before?.name, slug: before?.slug },
|
|
105
|
+
after: { name: updated.name, slug: updated.slug },
|
|
106
|
+
ip: getClientIp(req),
|
|
107
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
res.json({ org: updated });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
adapter.logger.error('[orgs] PATCH /:orgId', { error: (e as Error).message });
|
|
114
|
+
res.status(500).json({ error: 'Failed to update organization' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// DELETE /orgs/:orgId — soft delete (owner only), requires confirmName
|
|
119
|
+
router.delete('/:orgId', authMiddleware, requireRole('owner'), async (req, res) => {
|
|
120
|
+
const { orgId, userId } = req as AuthenticatedRequest;
|
|
121
|
+
// Accept both confirmName and confirmOrgName for compatibility
|
|
122
|
+
const { confirmName, confirmOrgName } = req.body as { confirmName?: string; confirmOrgName?: string };
|
|
123
|
+
const confirm = confirmName ?? confirmOrgName;
|
|
124
|
+
try {
|
|
125
|
+
const org = await getOrg(pool, orgId);
|
|
126
|
+
if (!org) {
|
|
127
|
+
res.status(404).json({ error: 'Organization not found' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!confirm || confirm !== org.name) {
|
|
131
|
+
res.status(422).json({ error: 'Confirmation name does not match organization name' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Transfer lock: cannot delete org while ownership transfer is pending
|
|
136
|
+
const pendingTransfer = await getPendingTransfer(pool, orgId);
|
|
137
|
+
if (pendingTransfer) {
|
|
138
|
+
res.status(409).json({ error: 'Cannot delete organization while an ownership transfer is pending. Cancel the transfer first.' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await softDeleteOrg(pool, orgId, userId);
|
|
143
|
+
|
|
144
|
+
if (flags.enableAuditLog) {
|
|
145
|
+
await writeAuditEvent({
|
|
146
|
+
pool,
|
|
147
|
+
orgId,
|
|
148
|
+
actorUserId: userId,
|
|
149
|
+
action: 'org.deleted',
|
|
150
|
+
targetType: 'org',
|
|
151
|
+
targetId: orgId,
|
|
152
|
+
ip: getClientIp(req),
|
|
153
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const members = await listOrgMembers(pool, orgId, { includeRemoved: false });
|
|
159
|
+
const userIds = members.map(m => m.user_id);
|
|
160
|
+
const users = await adapter.getUsersByIds(userIds);
|
|
161
|
+
const scheduledFor = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
162
|
+
for (const user of users) {
|
|
163
|
+
await adapter.sendOrgDeletionNotice({
|
|
164
|
+
to: user.email,
|
|
165
|
+
orgName: org.name,
|
|
166
|
+
scheduledFor,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
adapter.logger.warn('[orgs] Failed to send deletion notices', { error: (e as Error).message });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
res.json({ message: 'Organization scheduled for deletion in 30 days' });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
adapter.logger.error('[orgs] DELETE /:orgId', { error: (e as Error).message });
|
|
176
|
+
res.status(500).json({ error: 'Failed to delete organization' });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// GET /orgs/:orgId/members — list members (member+)
|
|
181
|
+
router.get('/:orgId/members', authMiddleware, async (req, res) => {
|
|
182
|
+
const { orgId } = req as AuthenticatedRequest;
|
|
183
|
+
try {
|
|
184
|
+
const members = await listOrgMembers(pool, orgId);
|
|
185
|
+
const userIds = members.map(m => m.user_id);
|
|
186
|
+
const users = await adapter.getUsersByIds(userIds);
|
|
187
|
+
const userMap = new Map(users.map(u => [u.id, u]));
|
|
188
|
+
const enriched = members.map(m => ({ ...m, user: userMap.get(m.user_id) }));
|
|
189
|
+
res.json({ members: enriched });
|
|
190
|
+
} catch (e) {
|
|
191
|
+
adapter.logger.error('[orgs] GET /:orgId/members', { error: (e as Error).message });
|
|
192
|
+
res.status(500).json({ error: 'Failed to fetch members' });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// GET /orgs/:orgId/members/former — former members (admin+)
|
|
197
|
+
router.get('/:orgId/members/former', authMiddleware, requireRole('admin'), async (req, res) => {
|
|
198
|
+
const { orgId } = req as AuthenticatedRequest;
|
|
199
|
+
try {
|
|
200
|
+
const allMembers = await listOrgMembers(pool, orgId, { includeRemoved: true });
|
|
201
|
+
const former = allMembers.filter(m => m.removed_at !== null);
|
|
202
|
+
res.json({ members: former });
|
|
203
|
+
} catch (e) {
|
|
204
|
+
adapter.logger.error('[orgs] GET /:orgId/members/former', { error: (e as Error).message });
|
|
205
|
+
res.status(500).json({ error: 'Failed to fetch former members' });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// DELETE /orgs/:orgId/members/:userId — remove member (admin+)
|
|
210
|
+
router.delete('/:orgId/members/:userId', authMiddleware, requireRole('admin'), async (req, res) => {
|
|
211
|
+
const { orgId, userId: actorId, userRole } = req as AuthenticatedRequest;
|
|
212
|
+
const targetUserId = parseInt(req.params.userId, 10);
|
|
213
|
+
const { reason } = req.body as { reason?: string };
|
|
214
|
+
|
|
215
|
+
if (isNaN(targetUserId)) {
|
|
216
|
+
res.status(400).json({ error: 'Invalid user ID' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (targetUserId === actorId) {
|
|
220
|
+
res.status(400).json({ message: 'Cannot remove yourself: you are the owner of this organization' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const targetMemberResult = await pool.query(
|
|
226
|
+
`SELECT role FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
227
|
+
[orgId, targetUserId]
|
|
228
|
+
);
|
|
229
|
+
if (targetMemberResult.rows.length === 0) {
|
|
230
|
+
res.status(404).json({ error: 'Member not found' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const targetRole = targetMemberResult.rows[0].role;
|
|
234
|
+
|
|
235
|
+
if (userRole === 'admin' && (targetRole === 'owner' || targetRole === 'admin')) {
|
|
236
|
+
res.status(403).json({ error: 'Admins cannot remove owners or other admins' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Transfer lock: cannot remove a user involved in a pending transfer
|
|
241
|
+
const pendingTransferForRemove = await getPendingTransfer(pool, orgId);
|
|
242
|
+
if (pendingTransferForRemove &&
|
|
243
|
+
(pendingTransferForRemove.from_user_id === targetUserId ||
|
|
244
|
+
pendingTransferForRemove.to_user_id === targetUserId)) {
|
|
245
|
+
res.status(409).json({ error: 'Cannot remove a member involved in a pending ownership transfer. Cancel the transfer first.' });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await removeMember(pool, { orgId, userId: targetUserId, removedByUserId: actorId, reason });
|
|
250
|
+
|
|
251
|
+
if (flags.enableAuditLog) {
|
|
252
|
+
await writeAuditEvent({
|
|
253
|
+
pool,
|
|
254
|
+
orgId,
|
|
255
|
+
actorUserId: actorId,
|
|
256
|
+
action: 'member.removed',
|
|
257
|
+
targetType: 'user',
|
|
258
|
+
targetId: targetUserId,
|
|
259
|
+
before: { role: targetRole },
|
|
260
|
+
reason: reason ?? null,
|
|
261
|
+
ip: getClientIp(req),
|
|
262
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
res.json({ message: 'Member removed successfully' });
|
|
267
|
+
} catch (e) {
|
|
268
|
+
adapter.logger.error('[orgs] DELETE /:orgId/members/:userId', { error: (e as Error).message });
|
|
269
|
+
res.status(500).json({ error: 'Failed to remove member' });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// PATCH /orgs/:orgId/members/:userId/role — change role (admin+)
|
|
274
|
+
router.patch('/:orgId/members/:userId/role', authMiddleware, requireRole('admin'), async (req, res) => {
|
|
275
|
+
const { orgId, userId: actorId, userRole } = req as AuthenticatedRequest;
|
|
276
|
+
const targetUserId = parseInt(req.params.userId, 10);
|
|
277
|
+
const { role: newRole } = req.body as { role?: string };
|
|
278
|
+
|
|
279
|
+
if (isNaN(targetUserId)) {
|
|
280
|
+
res.status(400).json({ error: 'Invalid user ID' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!newRole) {
|
|
284
|
+
res.status(400).json({ error: 'role is required' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await validateRoleChange(pool, { orgId, actorRole: userRole, targetUserId, newRole: newRole as OrgRole });
|
|
290
|
+
|
|
291
|
+
// Transfer lock: cannot change role of a user involved in a pending transfer
|
|
292
|
+
const pendingTransferForPatch = await getPendingTransfer(pool, orgId);
|
|
293
|
+
if (pendingTransferForPatch &&
|
|
294
|
+
(pendingTransferForPatch.from_user_id === targetUserId ||
|
|
295
|
+
pendingTransferForPatch.to_user_id === targetUserId)) {
|
|
296
|
+
res.status(409).json({ error: 'Cannot change role of a member involved in a pending ownership transfer. Cancel the transfer first.' });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const before = await pool.query(
|
|
301
|
+
`SELECT role FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
302
|
+
[orgId, targetUserId]
|
|
303
|
+
);
|
|
304
|
+
const updated = await changeRole(pool, { orgId, userId: targetUserId, newRole: newRole as OrgRole, changedByUserId: actorId });
|
|
305
|
+
|
|
306
|
+
if (flags.enableAuditLog) {
|
|
307
|
+
await writeAuditEvent({
|
|
308
|
+
pool,
|
|
309
|
+
orgId,
|
|
310
|
+
actorUserId: actorId,
|
|
311
|
+
action: 'member.role_changed',
|
|
312
|
+
targetType: 'user',
|
|
313
|
+
targetId: targetUserId,
|
|
314
|
+
before: { role: before.rows[0]?.role },
|
|
315
|
+
after: { role: newRole },
|
|
316
|
+
ip: getClientIp(req),
|
|
317
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
res.json({ membership: updated });
|
|
322
|
+
} catch (e) {
|
|
323
|
+
const msg = (e as Error).message;
|
|
324
|
+
adapter.logger.error('[orgs] PATCH /:orgId/members/:userId/role', { error: msg });
|
|
325
|
+
if (msg.includes('Cannot') || msg.includes('Requires') || msg.includes('cannot')) {
|
|
326
|
+
res.status(403).json({ error: msg });
|
|
327
|
+
} else {
|
|
328
|
+
res.status(500).json({ error: 'Failed to change role' });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// PATCH /orgs/:orgId/members/:userId — alias without /role suffix (for compatibility)
|
|
334
|
+
router.patch('/:orgId/members/:userId', authMiddleware, requireRole('admin'), async (req, res) => {
|
|
335
|
+
const { orgId, userId: actorId, userRole } = req as AuthenticatedRequest;
|
|
336
|
+
const targetUserId = parseInt(req.params.userId, 10);
|
|
337
|
+
const { role: newRole } = req.body as { role?: string };
|
|
338
|
+
|
|
339
|
+
if (isNaN(targetUserId)) {
|
|
340
|
+
res.status(400).json({ error: 'Invalid user ID' });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (!newRole) {
|
|
344
|
+
res.status(400).json({ error: 'role is required' });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await validateRoleChange(pool, { orgId, actorRole: userRole, targetUserId, newRole: newRole as OrgRole });
|
|
350
|
+
|
|
351
|
+
// Transfer lock: cannot change role of a user involved in a pending transfer
|
|
352
|
+
const pendingTransferForPatch = await getPendingTransfer(pool, orgId);
|
|
353
|
+
if (pendingTransferForPatch &&
|
|
354
|
+
(pendingTransferForPatch.from_user_id === targetUserId ||
|
|
355
|
+
pendingTransferForPatch.to_user_id === targetUserId)) {
|
|
356
|
+
res.status(409).json({ error: 'Cannot change role of a member involved in a pending ownership transfer. Cancel the transfer first.' });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const before = await pool.query(
|
|
361
|
+
`SELECT role FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
362
|
+
[orgId, targetUserId]
|
|
363
|
+
);
|
|
364
|
+
const updated = await changeRole(pool, { orgId, userId: targetUserId, newRole: newRole as OrgRole, changedByUserId: actorId });
|
|
365
|
+
|
|
366
|
+
if (flags.enableAuditLog) {
|
|
367
|
+
await writeAuditEvent({
|
|
368
|
+
pool,
|
|
369
|
+
orgId,
|
|
370
|
+
actorUserId: actorId,
|
|
371
|
+
action: 'member.role_changed',
|
|
372
|
+
targetType: 'user',
|
|
373
|
+
targetId: targetUserId,
|
|
374
|
+
before: { role: before.rows[0]?.role },
|
|
375
|
+
after: { role: newRole },
|
|
376
|
+
ip: getClientIp(req),
|
|
377
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
res.json({ membership: updated });
|
|
382
|
+
} catch (e) {
|
|
383
|
+
const msg = (e as Error).message;
|
|
384
|
+
adapter.logger.error('[orgs] PATCH /:orgId/members/:userId', { error: msg });
|
|
385
|
+
if (msg.includes('Cannot') || msg.includes('Requires') || msg.includes('cannot')) {
|
|
386
|
+
res.status(403).json({ error: msg });
|
|
387
|
+
} else {
|
|
388
|
+
res.status(500).json({ error: 'Failed to change role' });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
// GET /orgs/:orgId/members/:userId/cascade-preview — preview cascade effects of removing a member (admin+)
|
|
395
|
+
router.get('/:orgId/members/:userId/cascade-preview', authMiddleware, requireRole('admin'), async (req, res) => {
|
|
396
|
+
const { orgId } = req as AuthenticatedRequest;
|
|
397
|
+
const targetUserId = parseInt(req.params.userId, 10);
|
|
398
|
+
if (isNaN(targetUserId)) {
|
|
399
|
+
res.status(400).json({ error: 'Invalid user ID' });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const membershipResult = await pool.query(
|
|
404
|
+
`SELECT * FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
405
|
+
[orgId, targetUserId]
|
|
406
|
+
);
|
|
407
|
+
if (membershipResult.rows.length === 0) {
|
|
408
|
+
res.status(404).json({ error: 'Member not found' });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const membership = membershipResult.rows[0];
|
|
412
|
+
|
|
413
|
+
const invitationsResult = await pool.query(
|
|
414
|
+
`SELECT * FROM tm_invitations
|
|
415
|
+
WHERE org_id = $1 AND invited_by_user_id = $2
|
|
416
|
+
AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()`,
|
|
417
|
+
[orgId, targetUserId]
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
res.json({ membership, pendingInvitations: invitationsResult.rows });
|
|
421
|
+
} catch (e) {
|
|
422
|
+
adapter.logger.error('[orgs] GET cascade-preview', { error: (e as Error).message });
|
|
423
|
+
res.status(500).json({ error: 'Failed to fetch cascade preview' });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return router;
|
|
428
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
import {
|
|
7
|
+
initiateTransfer,
|
|
8
|
+
acceptTransfer,
|
|
9
|
+
cancelTransfer,
|
|
10
|
+
getPendingTransfer,
|
|
11
|
+
} from '../services/ownership.service.js';
|
|
12
|
+
import { writeAuditEvent, getClientIp } from '../services/audit.service.js';
|
|
13
|
+
|
|
14
|
+
export function createTransferRouter(
|
|
15
|
+
pool: Pool,
|
|
16
|
+
adapter: ServerModuleAdapter,
|
|
17
|
+
flags: TeamManagementFeatureFlags,
|
|
18
|
+
baseUrl: string
|
|
19
|
+
): Router {
|
|
20
|
+
const router = Router({ mergeParams: true });
|
|
21
|
+
const authMiddleware = requireMembership(pool, adapter);
|
|
22
|
+
|
|
23
|
+
function featureCheck(res: import('express').Response): boolean {
|
|
24
|
+
if (!flags.enableOwnershipTransfer) {
|
|
25
|
+
res.status(501).json({ error: 'Ownership transfer is not enabled' });
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
router.get('/:orgId/transfer', authMiddleware, async (req, res) => {
|
|
32
|
+
if (!featureCheck(res)) return;
|
|
33
|
+
const { orgId } = req as AuthenticatedRequest;
|
|
34
|
+
try {
|
|
35
|
+
const transfer = await getPendingTransfer(pool, orgId);
|
|
36
|
+
res.json({ transfer });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
adapter.logger.error('[transfer] GET', { error: (e as Error).message });
|
|
39
|
+
res.status(500).json({ error: 'Failed to fetch transfer' });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.post('/:orgId/transfer', authMiddleware, requireRole('owner'), async (req, res) => {
|
|
44
|
+
if (!featureCheck(res)) return;
|
|
45
|
+
const { orgId, userId } = req as AuthenticatedRequest;
|
|
46
|
+
const { toUserId } = req.body as { toUserId?: number };
|
|
47
|
+
if (!toUserId) { res.status(400).json({ error: 'toUserId is required' }); return; }
|
|
48
|
+
try {
|
|
49
|
+
const transfer = await initiateTransfer(pool, adapter, { orgId, fromUserId: userId, toUserId, baseUrl });
|
|
50
|
+
if (flags.enableAuditLog) {
|
|
51
|
+
await writeAuditEvent({ pool, orgId, actorUserId: userId, action: 'ownership.transfer_initiated',
|
|
52
|
+
targetType: 'user', targetId: toUserId, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null });
|
|
53
|
+
}
|
|
54
|
+
res.status(201).json({ transfer });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const msg = (e as Error).message;
|
|
57
|
+
adapter.logger.error('[transfer] POST initiate', { error: msg });
|
|
58
|
+
if (msg.includes('not a member') || msg.includes('admin') || msg.includes('pending')) {
|
|
59
|
+
res.status(422).json({ error: msg });
|
|
60
|
+
} else {
|
|
61
|
+
res.status(500).json({ error: 'Failed to initiate transfer' });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
router.post('/:orgId/transfer/accept', authMiddleware, async (req, res) => {
|
|
67
|
+
if (!featureCheck(res)) return;
|
|
68
|
+
const { orgId, userId } = req as AuthenticatedRequest;
|
|
69
|
+
try {
|
|
70
|
+
await acceptTransfer(pool, adapter, { orgId, acceptingUserId: userId });
|
|
71
|
+
if (flags.enableAuditLog) {
|
|
72
|
+
await writeAuditEvent({ pool, orgId, actorUserId: userId, action: 'ownership.transfer_accepted',
|
|
73
|
+
targetType: 'org', targetId: orgId, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null });
|
|
74
|
+
}
|
|
75
|
+
res.json({ message: 'Ownership transfer accepted. You are now the owner.' });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
const msg = (e as Error).message;
|
|
78
|
+
adapter.logger.error('[transfer] POST accept', { error: msg });
|
|
79
|
+
if (msg.includes('No valid') || msg.includes('Only the designated')) {
|
|
80
|
+
res.status(422).json({ error: msg });
|
|
81
|
+
} else {
|
|
82
|
+
res.status(500).json({ error: 'Failed to accept transfer' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
router.delete('/:orgId/transfer', authMiddleware, async (req, res) => {
|
|
88
|
+
if (!featureCheck(res)) return;
|
|
89
|
+
const { orgId, userId, userRole } = req as AuthenticatedRequest;
|
|
90
|
+
try {
|
|
91
|
+
const pending = await getPendingTransfer(pool, orgId);
|
|
92
|
+
if (!pending) { res.status(404).json({ error: 'No pending transfer found' }); return; }
|
|
93
|
+
if (userRole !== 'owner' && pending.to_user_id !== userId) {
|
|
94
|
+
res.status(403).json({ error: 'Only the initiating owner or designated recipient can cancel this transfer' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await cancelTransfer(pool, { orgId, cancelledByUserId: userId });
|
|
98
|
+
if (flags.enableAuditLog) {
|
|
99
|
+
await writeAuditEvent({ pool, orgId, actorUserId: userId, action: 'ownership.transfer_cancelled',
|
|
100
|
+
targetType: 'org', targetId: orgId, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null });
|
|
101
|
+
}
|
|
102
|
+
res.json({ message: 'Transfer cancelled' });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
adapter.logger.error('[transfer] DELETE cancel', { error: (e as Error).message });
|
|
105
|
+
res.status(500).json({ error: 'Failed to cancel transfer' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return router;
|
|
110
|
+
}
|
|
File without changes
|