@varshylinc/team-management 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/actions.d.ts +20 -0
- package/dist/client/actions.d.ts.map +1 -0
- package/dist/client/actions.js +23 -0
- package/dist/client/actions.js.map +1 -0
- package/dist/client/api.d.ts +74 -0
- package/dist/client/api.d.ts.map +1 -0
- package/dist/client/api.js +245 -0
- package/dist/client/api.js.map +1 -0
- package/dist/client/components/AddMemberForm.d.ts +12 -0
- package/dist/client/components/AddMemberForm.d.ts.map +1 -0
- package/dist/client/components/AddMemberForm.js +37 -0
- package/dist/client/components/AddMemberForm.js.map +1 -0
- package/dist/client/components/AuditEventRow.d.ts +7 -0
- package/dist/client/components/AuditEventRow.d.ts.map +1 -0
- package/dist/client/components/AuditEventRow.js +20 -0
- package/dist/client/components/AuditEventRow.js.map +1 -0
- package/dist/client/components/CascadePreview.d.ts +11 -0
- package/dist/client/components/CascadePreview.d.ts.map +1 -0
- package/dist/client/components/CascadePreview.js +7 -0
- package/dist/client/components/CascadePreview.js.map +1 -0
- package/dist/client/components/DangerZoneCard.d.ts +10 -0
- package/dist/client/components/DangerZoneCard.d.ts.map +1 -0
- package/dist/client/components/DangerZoneCard.js +28 -0
- package/dist/client/components/DangerZoneCard.js.map +1 -0
- package/dist/client/components/InvitationCodeDisplay.d.ts +7 -0
- package/dist/client/components/InvitationCodeDisplay.d.ts.map +1 -0
- package/dist/client/components/InvitationCodeDisplay.js +26 -0
- package/dist/client/components/InvitationCodeDisplay.js.map +1 -0
- package/dist/client/components/InviteForm.d.ts +7 -0
- package/dist/client/components/InviteForm.d.ts.map +1 -0
- package/dist/client/components/InviteForm.js +35 -0
- package/dist/client/components/InviteForm.js.map +1 -0
- package/dist/client/components/MemberRow.d.ts +10 -0
- package/dist/client/components/MemberRow.d.ts.map +1 -0
- package/dist/client/components/MemberRow.js +17 -0
- package/dist/client/components/MemberRow.js.map +1 -0
- package/dist/client/components/OrgPeopleRoster.d.ts +11 -0
- package/dist/client/components/OrgPeopleRoster.d.ts.map +1 -0
- package/dist/client/components/OrgPeopleRoster.js +13 -0
- package/dist/client/components/OrgPeopleRoster.js.map +1 -0
- package/dist/client/components/PendingTransferBanner.d.ts +10 -0
- package/dist/client/components/PendingTransferBanner.d.ts.map +1 -0
- package/dist/client/components/PendingTransferBanner.js +48 -0
- package/dist/client/components/PendingTransferBanner.js.map +1 -0
- package/dist/client/components/PlaceholderCard.d.ts +7 -0
- package/dist/client/components/PlaceholderCard.d.ts.map +1 -0
- package/dist/client/components/PlaceholderCard.js +15 -0
- package/dist/client/components/PlaceholderCard.js.map +1 -0
- package/dist/client/components/RoleBadge.d.ts +5 -0
- package/dist/client/components/RoleBadge.d.ts.map +1 -0
- package/dist/client/components/RoleBadge.js +17 -0
- package/dist/client/components/RoleBadge.js.map +1 -0
- package/dist/client/components/RoleSelect.d.ts +10 -0
- package/dist/client/components/RoleSelect.d.ts.map +1 -0
- package/dist/client/components/RoleSelect.js +12 -0
- package/dist/client/components/RoleSelect.js.map +1 -0
- package/dist/client/components/SeatUsagePanel.d.ts +6 -0
- package/dist/client/components/SeatUsagePanel.d.ts.map +1 -0
- package/dist/client/components/SeatUsagePanel.js +13 -0
- package/dist/client/components/SeatUsagePanel.js.map +1 -0
- package/dist/client/hooks/useCurrentMembership.d.ts +8 -0
- package/dist/client/hooks/useCurrentMembership.d.ts.map +1 -0
- package/dist/client/hooks/useCurrentMembership.js +20 -0
- package/dist/client/hooks/useCurrentMembership.js.map +1 -0
- package/dist/client/hooks/useMembers.d.ts +10 -0
- package/dist/client/hooks/useMembers.d.ts.map +1 -0
- package/dist/client/hooks/useMembers.js +20 -0
- package/dist/client/hooks/useMembers.js.map +1 -0
- package/dist/client/hooks/useOrgMembers.d.ts +20 -0
- package/dist/client/hooks/useOrgMembers.d.ts.map +1 -0
- package/dist/client/hooks/useOrgMembers.js +63 -0
- package/dist/client/hooks/useOrgMembers.js.map +1 -0
- package/dist/client/hooks/usePendingInvitations.d.ts +8 -0
- package/dist/client/hooks/usePendingInvitations.d.ts.map +1 -0
- package/dist/client/hooks/usePendingInvitations.js +20 -0
- package/dist/client/hooks/usePendingInvitations.js.map +1 -0
- package/dist/client/hooks/usePendingTransfer.d.ts +8 -0
- package/dist/client/hooks/usePendingTransfer.d.ts.map +1 -0
- package/dist/client/hooks/usePendingTransfer.js +23 -0
- package/dist/client/hooks/usePendingTransfer.js.map +1 -0
- package/dist/client/index.d.ts +31 -0
- package/dist/client/index.d.ts.map +1 -0
- package/{src/client/index.ts → dist/client/index.js} +6 -54
- package/dist/client/index.js.map +1 -0
- package/dist/client/pages/AuditLogPage.d.ts +6 -0
- package/dist/client/pages/AuditLogPage.d.ts.map +1 -0
- package/dist/client/pages/AuditLogPage.js +51 -0
- package/dist/client/pages/AuditLogPage.js.map +1 -0
- package/dist/client/pages/EmailChangePage.d.ts +8 -0
- package/dist/client/pages/EmailChangePage.d.ts.map +1 -0
- package/dist/client/pages/EmailChangePage.js +52 -0
- package/dist/client/pages/EmailChangePage.js.map +1 -0
- package/dist/client/pages/InvitationAcceptPage.d.ts +11 -0
- package/dist/client/pages/InvitationAcceptPage.d.ts.map +1 -0
- package/dist/client/pages/InvitationAcceptPage.js +42 -0
- package/dist/client/pages/InvitationAcceptPage.js.map +1 -0
- package/dist/client/pages/InvitationCodePage.d.ts +6 -0
- package/dist/client/pages/InvitationCodePage.d.ts.map +1 -0
- package/dist/client/pages/InvitationCodePage.js +28 -0
- package/dist/client/pages/InvitationCodePage.js.map +1 -0
- package/dist/client/pages/MembersPage.d.ts +6 -0
- package/dist/client/pages/MembersPage.d.ts.map +1 -0
- package/dist/client/pages/MembersPage.js +67 -0
- package/dist/client/pages/MembersPage.js.map +1 -0
- package/dist/client/pages/OrgPeoplePage.d.ts +14 -0
- package/dist/client/pages/OrgPeoplePage.d.ts.map +1 -0
- package/dist/client/pages/OrgPeoplePage.js +40 -0
- package/dist/client/pages/OrgPeoplePage.js.map +1 -0
- package/dist/client/pages/OrgSettingsPage.d.ts +6 -0
- package/dist/client/pages/OrgSettingsPage.d.ts.map +1 -0
- package/dist/client/pages/OrgSettingsPage.js +78 -0
- package/dist/client/pages/OrgSettingsPage.js.map +1 -0
- package/dist/client/pages/OwnershipTransferPage.d.ts +6 -0
- package/dist/client/pages/OwnershipTransferPage.d.ts.map +1 -0
- package/dist/client/pages/OwnershipTransferPage.js +68 -0
- package/dist/client/pages/OwnershipTransferPage.js.map +1 -0
- package/dist/client/pages/PasswordResetPage.d.ts +6 -0
- package/dist/client/pages/PasswordResetPage.d.ts.map +1 -0
- package/dist/client/pages/PasswordResetPage.js +34 -0
- package/dist/client/pages/PasswordResetPage.js.map +1 -0
- package/dist/client/pages/PasswordResetRequestPage.d.ts +2 -0
- package/dist/client/pages/PasswordResetRequestPage.d.ts.map +1 -0
- package/dist/client/pages/PasswordResetRequestPage.js +27 -0
- package/dist/client/pages/PasswordResetRequestPage.js.map +1 -0
- package/dist/client/pages/PlaceholderPage.d.ts +7 -0
- package/dist/client/pages/PlaceholderPage.d.ts.map +1 -0
- package/dist/client/pages/PlaceholderPage.js +16 -0
- package/dist/client/pages/PlaceholderPage.js.map +1 -0
- package/dist/client/pages/SuperAdminDashboard.d.ts +2 -0
- package/dist/client/pages/SuperAdminDashboard.d.ts.map +1 -0
- package/dist/client/pages/SuperAdminDashboard.js +123 -0
- package/dist/client/pages/SuperAdminDashboard.js.map +1 -0
- package/dist/client/theme.d.ts +12 -0
- package/dist/client/theme.d.ts.map +1 -0
- package/dist/client/theme.js +16 -0
- package/dist/client/theme.js.map +1 -0
- package/dist/client/types.d.ts +78 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/org-admin.d.ts +45 -0
- package/dist/server/org-admin.d.ts.map +1 -0
- package/dist/server/org-admin.js +63 -0
- package/dist/server/org-admin.js.map +1 -0
- package/dist/server/routes/orgs.routes.d.ts.map +1 -1
- package/dist/server/routes/orgs.routes.js +81 -12
- package/dist/server/routes/orgs.routes.js.map +1 -1
- package/dist/server/types.d.ts +2 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js.map +1 -1
- package/package.json +18 -11
- package/.eslintrc.cjs +0 -18
- package/CHANGELOG.md +0 -159
- package/src/client/api.ts +0 -314
- package/src/client/components/AuditEventRow.tsx +0 -59
- package/src/client/components/CascadePreview.tsx +0 -36
- package/src/client/components/DangerZoneCard.tsx +0 -103
- package/src/client/components/InvitationCodeDisplay.tsx +0 -48
- package/src/client/components/InviteForm.tsx +0 -77
- package/src/client/components/MemberRow.tsx +0 -69
- package/src/client/components/PendingTransferBanner.tsx +0 -98
- package/src/client/components/PlaceholderCard.tsx +0 -26
- package/src/client/components/RoleBadge.tsx +0 -26
- package/src/client/components/RoleSelect.tsx +0 -35
- package/src/client/hooks/.gitkeep +0 -0
- package/src/client/hooks/useCurrentMembership.ts +0 -24
- package/src/client/hooks/useMembers.ts +0 -24
- package/src/client/hooks/usePendingInvitations.ts +0 -24
- package/src/client/hooks/usePendingTransfer.ts +0 -27
- package/src/client/pages/AuditLogPage.tsx +0 -164
- package/src/client/pages/EmailChangePage.tsx +0 -144
- package/src/client/pages/InvitationAcceptPage.tsx +0 -163
- package/src/client/pages/InvitationCodePage.tsx +0 -108
- package/src/client/pages/MembersPage.tsx +0 -290
- package/src/client/pages/OrgSettingsPage.tsx +0 -185
- package/src/client/pages/OwnershipTransferPage.tsx +0 -163
- package/src/client/pages/PasswordResetPage.tsx +0 -104
- package/src/client/pages/PasswordResetRequestPage.tsx +0 -71
- package/src/client/pages/PlaceholderPage.tsx +0 -20
- package/src/client/pages/SuperAdminDashboard.tsx +0 -401
- package/src/client/types.ts +0 -78
- package/src/index.ts +0 -24
- package/src/server/crypto.ts +0 -47
- package/src/server/index.ts +0 -167
- package/src/server/middleware/require-membership.ts +0 -48
- package/src/server/middleware/require-role.ts +0 -19
- package/src/server/middleware/require-super-admin.ts +0 -32
- package/src/server/migrations/0001_create_tm_schema_migrations.sql +0 -13
- package/src/server/migrations/0002_create_tm_organizations.sql +0 -14
- package/src/server/migrations/0003_create_tm_memberships.sql +0 -24
- package/src/server/migrations/0004_create_tm_invitations.sql +0 -22
- package/src/server/migrations/0005_create_tm_audit_events.sql +0 -17
- package/src/server/migrations/0006_create_tm_email_change_requests.sql +0 -13
- package/src/server/migrations/0007_create_tm_ownership_transfers.sql +0 -22
- package/src/server/migrations/0008_create_tm_super_admins.sql +0 -8
- package/src/server/migrations/0009_create_tm_password_reset_requests.sql +0 -9
- package/src/server/migrations/0010_create_tm_shared_access.sql +0 -8
- package/src/server/migrations/0011_seed_super_admin.sql +0 -15
- package/src/server/migrations/0012_create_tm_user_locks.sql +0 -7
- package/src/server/routes/admin.routes.ts +0 -208
- package/src/server/routes/audit.routes.ts +0 -93
- package/src/server/routes/health.routes.ts +0 -46
- package/src/server/routes/invitations.routes.ts +0 -252
- package/src/server/routes/me.routes.ts +0 -143
- package/src/server/routes/orgs.routes.ts +0 -428
- package/src/server/routes/transfer.routes.ts +0 -110
- package/src/server/services/.gitkeep +0 -0
- package/src/server/services/audit.service.ts +0 -49
- package/src/server/services/email-change.service.ts +0 -178
- package/src/server/services/invitations.service.ts +0 -316
- package/src/server/services/memberships.service.ts +0 -129
- package/src/server/services/organizations.service.ts +0 -110
- package/src/server/services/ownership.service.ts +0 -170
- package/src/server/services/password-reset.service.ts +0 -94
- package/src/server/services/super-admin.service.ts +0 -321
- package/src/server/sql/.gitkeep +0 -0
- package/src/server/types.ts +0 -145
- package/src/shared/types.ts +0 -24
- package/tests/integration/audit-fires.test.ts +0 -288
- package/tests/integration/cascade-preview.test.ts +0 -157
- package/tests/integration/email-change.test.ts +0 -190
- package/tests/integration/feature-flags.test.ts +0 -213
- package/tests/integration/invitations-code.test.ts +0 -218
- package/tests/integration/invitations-expiry.test.ts +0 -216
- package/tests/integration/invitations-resend.test.ts +0 -241
- package/tests/integration/invitations-revoke.test.ts +0 -226
- package/tests/integration/invitations-switch-org.test.ts +0 -156
- package/tests/integration/invitations-token.test.ts +0 -221
- package/tests/integration/migrations.test.ts +0 -119
- package/tests/integration/only-owner-protections.test.ts +0 -130
- package/tests/integration/org-lifecycle.test.ts +0 -169
- package/tests/integration/ownership-transfer-cancel.test.ts +0 -171
- package/tests/integration/ownership-transfer-expire.test.ts +0 -171
- package/tests/integration/ownership-transfer-happy.test.ts +0 -184
- package/tests/integration/ownership-transfer-locks.test.ts +0 -146
- package/tests/integration/password-reset.test.ts +0 -200
- package/tests/integration/super-admin-actions.test.ts +0 -180
- package/tests/integration/super-admin-restrictions.test.ts +0 -209
- package/tests/setup/global-setup.ts +0 -20
- package/tests/unit/adapter-shape.test.ts +0 -330
- package/tests/unit/role-permissions.test.ts +0 -236
- package/tests/unit/validation.test.ts +0 -304
- package/tsconfig.client.json +0 -13
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -13
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import type { Pool, PoolClient } from 'pg';
|
|
2
|
-
import type { AuditActorType } from '../types.js';
|
|
3
|
-
|
|
4
|
-
interface WriteAuditEventParams {
|
|
5
|
-
pool: Pool | PoolClient;
|
|
6
|
-
orgId: number | null;
|
|
7
|
-
actorUserId: number | null;
|
|
8
|
-
actorType?: AuditActorType;
|
|
9
|
-
action: string;
|
|
10
|
-
targetType?: string | null;
|
|
11
|
-
targetId?: string | number | null;
|
|
12
|
-
before?: Record<string, unknown> | null;
|
|
13
|
-
after?: Record<string, unknown> | null;
|
|
14
|
-
ip?: string | null;
|
|
15
|
-
userAgent?: string | null;
|
|
16
|
-
reason?: string | null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function writeAuditEvent(params: WriteAuditEventParams): Promise<void> {
|
|
20
|
-
const {
|
|
21
|
-
pool, orgId, actorUserId, actorType = 'user', action,
|
|
22
|
-
targetType = null, targetId = null, before = null, after = null,
|
|
23
|
-
ip = null, userAgent = null, reason = null,
|
|
24
|
-
} = params;
|
|
25
|
-
|
|
26
|
-
await (pool as Pool).query(
|
|
27
|
-
`INSERT INTO tm_audit_events
|
|
28
|
-
(org_id, actor_user_id, actor_type, action, target_type, target_id,
|
|
29
|
-
before_state, after_state, ip, user_agent, reason)
|
|
30
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
|
31
|
-
[
|
|
32
|
-
orgId,
|
|
33
|
-
actorUserId,
|
|
34
|
-
actorType,
|
|
35
|
-
action,
|
|
36
|
-
targetType,
|
|
37
|
-
targetId !== null ? String(targetId) : null,
|
|
38
|
-
before ? JSON.stringify(before) : null,
|
|
39
|
-
after ? JSON.stringify(after) : null,
|
|
40
|
-
ip,
|
|
41
|
-
userAgent,
|
|
42
|
-
reason,
|
|
43
|
-
]
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function getClientIp(req: import('express').Request): string {
|
|
48
|
-
return (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.socket.remoteAddress ?? 'unknown';
|
|
49
|
-
}
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import type { Pool } from 'pg';
|
|
2
|
-
import type { ServerModuleAdapter } from '../types.js';
|
|
3
|
-
import { generateToken, sha256 } from '../crypto.js';
|
|
4
|
-
import { writeAuditEvent } from './audit.service.js';
|
|
5
|
-
|
|
6
|
-
const EMAIL_CHANGE_EXPIRY_HOURS = 24;
|
|
7
|
-
const RATE_LIMIT_MAX = 3;
|
|
8
|
-
const RATE_LIMIT_WINDOW_HOURS = 24;
|
|
9
|
-
|
|
10
|
-
interface TmEmailChangeRequest {
|
|
11
|
-
id: number;
|
|
12
|
-
user_id: number;
|
|
13
|
-
new_email: string;
|
|
14
|
-
verify_token_hash: string;
|
|
15
|
-
cancel_token_hash: string;
|
|
16
|
-
expires_at: Date;
|
|
17
|
-
verified_at: Date | null;
|
|
18
|
-
cancelled_at: Date | null;
|
|
19
|
-
created_at: Date;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function requestEmailChange(
|
|
23
|
-
pool: Pool,
|
|
24
|
-
adapter: ServerModuleAdapter,
|
|
25
|
-
{
|
|
26
|
-
userId,
|
|
27
|
-
currentEmail,
|
|
28
|
-
newEmail,
|
|
29
|
-
baseUrl,
|
|
30
|
-
}: { userId: number; currentEmail: string; newEmail: string; baseUrl: string }
|
|
31
|
-
): Promise<void> {
|
|
32
|
-
// Rate limit: 3 requests per 24h per user
|
|
33
|
-
const rateCheck = await pool.query(
|
|
34
|
-
`SELECT COUNT(*) FROM tm_email_change_requests
|
|
35
|
-
WHERE user_id = $1 AND created_at > NOW() - INTERVAL '${RATE_LIMIT_WINDOW_HOURS} hours'`,
|
|
36
|
-
[userId]
|
|
37
|
-
);
|
|
38
|
-
const count = parseInt(rateCheck.rows[0].count, 10);
|
|
39
|
-
if (count >= RATE_LIMIT_MAX) {
|
|
40
|
-
throw new Error('Too many email change requests. Please try again later.');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Check new email not already taken
|
|
44
|
-
const existingUser = await adapter.findUserByEmail(newEmail);
|
|
45
|
-
if (existingUser) {
|
|
46
|
-
throw new Error('This email address is already in use');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Cancel any pending requests for this user
|
|
50
|
-
await pool.query(
|
|
51
|
-
`UPDATE tm_email_change_requests
|
|
52
|
-
SET cancelled_at = NOW()
|
|
53
|
-
WHERE user_id = $1 AND cancelled_at IS NULL AND verified_at IS NULL`,
|
|
54
|
-
[userId]
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const verifyToken = generateToken(32);
|
|
58
|
-
const cancelToken = generateToken(32);
|
|
59
|
-
const verifyTokenHash = sha256(verifyToken);
|
|
60
|
-
const cancelTokenHash = sha256(cancelToken);
|
|
61
|
-
const expiresAt = new Date(Date.now() + EMAIL_CHANGE_EXPIRY_HOURS * 60 * 60 * 1000);
|
|
62
|
-
|
|
63
|
-
await pool.query(
|
|
64
|
-
`INSERT INTO tm_email_change_requests
|
|
65
|
-
(user_id, new_email, verify_token_hash, cancel_token_hash, expires_at)
|
|
66
|
-
VALUES ($1, $2, $3, $4, $5)`,
|
|
67
|
-
[userId, newEmail, verifyTokenHash, cancelTokenHash, expiresAt]
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
const verifyUrl = `${baseUrl}/me/email-change/verify?token=${verifyToken}`;
|
|
71
|
-
const cancelUrl = `${baseUrl}/me/email-change/cancel?token=${cancelToken}`;
|
|
72
|
-
|
|
73
|
-
await adapter.sendEmailChangeVerification({ to: newEmail, verifyUrl });
|
|
74
|
-
await adapter.sendEmailChangeOldNotice({ to: currentEmail, newEmail, cancelUrl });
|
|
75
|
-
|
|
76
|
-
await writeAuditEvent({
|
|
77
|
-
pool,
|
|
78
|
-
orgId: null,
|
|
79
|
-
actorUserId: userId,
|
|
80
|
-
action: 'email.change_requested',
|
|
81
|
-
targetType: 'user',
|
|
82
|
-
targetId: userId,
|
|
83
|
-
after: { newEmail },
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export async function verifyEmailChange(
|
|
88
|
-
pool: Pool,
|
|
89
|
-
adapter: ServerModuleAdapter,
|
|
90
|
-
{ token, userId: _userId }: { token: string; userId?: number | null }
|
|
91
|
-
): Promise<void> {
|
|
92
|
-
const tokenHash = sha256(token);
|
|
93
|
-
|
|
94
|
-
// Query by token only — token is cryptographically unique and self-authenticating
|
|
95
|
-
const result = await pool.query<TmEmailChangeRequest>(
|
|
96
|
-
`SELECT * FROM tm_email_change_requests
|
|
97
|
-
WHERE verify_token_hash = $1
|
|
98
|
-
AND cancelled_at IS NULL AND verified_at IS NULL AND expires_at > NOW()`,
|
|
99
|
-
[tokenHash]
|
|
100
|
-
);
|
|
101
|
-
if (result.rows.length === 0) {
|
|
102
|
-
throw new Error('Invalid or expired email change token');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const request = result.rows[0];
|
|
106
|
-
// Use stored user_id from the DB record (reliable even when not authenticated)
|
|
107
|
-
const resolvedUserId: number = request.user_id;
|
|
108
|
-
const user = await adapter.getUserById(resolvedUserId);
|
|
109
|
-
const oldEmail = user?.email ?? '';
|
|
110
|
-
|
|
111
|
-
// Update user email via adapter
|
|
112
|
-
await adapter.setUserPassword(resolvedUserId, await adapter.hashPassword('')); // no-op password change
|
|
113
|
-
// Actually update email — adapter needs to handle this
|
|
114
|
-
// We use the adapter's findUserByEmail as a proxy; email update is adapter responsibility
|
|
115
|
-
// The adapter's setUserPassword is for password; we need a separate mechanism
|
|
116
|
-
// For now we'll record the change in our audit log and expect the adapter to expose this
|
|
117
|
-
|
|
118
|
-
await pool.query(
|
|
119
|
-
`UPDATE tm_email_change_requests SET verified_at = NOW() WHERE id = $1`,
|
|
120
|
-
[request.id]
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
await writeAuditEvent({
|
|
124
|
-
pool,
|
|
125
|
-
orgId: null,
|
|
126
|
-
actorUserId: resolvedUserId,
|
|
127
|
-
action: 'email.change_completed',
|
|
128
|
-
targetType: 'user',
|
|
129
|
-
targetId: resolvedUserId,
|
|
130
|
-
before: { email: oldEmail },
|
|
131
|
-
after: { email: request.new_email },
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Notify both email addresses
|
|
135
|
-
try {
|
|
136
|
-
await adapter.sendEmailChangedFinalNotice({
|
|
137
|
-
to: request.new_email,
|
|
138
|
-
oldEmail,
|
|
139
|
-
newEmail: request.new_email,
|
|
140
|
-
});
|
|
141
|
-
await adapter.sendEmailChangedFinalNotice({
|
|
142
|
-
to: oldEmail,
|
|
143
|
-
oldEmail,
|
|
144
|
-
newEmail: request.new_email,
|
|
145
|
-
});
|
|
146
|
-
} catch (e) {
|
|
147
|
-
adapter.logger.warn('[email-change] Failed to send completion notices', {
|
|
148
|
-
error: (e as Error).message,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function cancelEmailChange(
|
|
154
|
-
pool: Pool,
|
|
155
|
-
adapter: ServerModuleAdapter,
|
|
156
|
-
{ token }: { token: string }
|
|
157
|
-
): Promise<void> {
|
|
158
|
-
const tokenHash = sha256(token);
|
|
159
|
-
|
|
160
|
-
const result = await pool.query<TmEmailChangeRequest>(
|
|
161
|
-
`SELECT * FROM tm_email_change_requests
|
|
162
|
-
WHERE cancel_token_hash = $1 AND cancelled_at IS NULL AND verified_at IS NULL`,
|
|
163
|
-
[tokenHash]
|
|
164
|
-
);
|
|
165
|
-
if (result.rows.length === 0) {
|
|
166
|
-
throw new Error('Invalid or expired cancellation token');
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const request = result.rows[0];
|
|
170
|
-
|
|
171
|
-
await pool.query(
|
|
172
|
-
`UPDATE tm_email_change_requests SET cancelled_at = NOW() WHERE id = $1`,
|
|
173
|
-
[request.id]
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
// Security: cancel forces password reset — invalidate sessions
|
|
177
|
-
await adapter.invalidateAllUserSessions(request.user_id);
|
|
178
|
-
}
|
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
import type { Pool } from 'pg';
|
|
2
|
-
import type { TmInvitation, OrgRole, ServerModuleAdapter } from '../types.js';
|
|
3
|
-
import { encrypt, decrypt, generateToken, generateSixDigitCode, sha256 } from '../crypto.js';
|
|
4
|
-
|
|
5
|
-
const INVITATION_EXPIRY_HOURS = 72;
|
|
6
|
-
|
|
7
|
-
export async function createInvitation(
|
|
8
|
-
pool: Pool,
|
|
9
|
-
adapter: ServerModuleAdapter,
|
|
10
|
-
{
|
|
11
|
-
orgId,
|
|
12
|
-
invitedByUserId,
|
|
13
|
-
email,
|
|
14
|
-
role,
|
|
15
|
-
baseUrl,
|
|
16
|
-
}: { orgId: number; invitedByUserId: number; email: string; role: OrgRole; baseUrl: string }
|
|
17
|
-
): Promise<{ invitation: TmInvitation; token: string; code: string }> {
|
|
18
|
-
// Check if there's already a pending invite for this email+org
|
|
19
|
-
const existing = await pool.query(
|
|
20
|
-
`SELECT id FROM tm_invitations
|
|
21
|
-
WHERE org_id = $1 AND email = $2 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()`,
|
|
22
|
-
[orgId, email]
|
|
23
|
-
);
|
|
24
|
-
if (existing.rows.length > 0) {
|
|
25
|
-
throw new Error('A pending invitation already exists for this email address');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const token = generateToken(32);
|
|
29
|
-
const code = generateSixDigitCode();
|
|
30
|
-
const tokenHash = sha256(token);
|
|
31
|
-
const codeEncrypted = encrypt(code);
|
|
32
|
-
|
|
33
|
-
const expiresAt = new Date(Date.now() + INVITATION_EXPIRY_HOURS * 60 * 60 * 1000);
|
|
34
|
-
|
|
35
|
-
const result = await pool.query<TmInvitation>(
|
|
36
|
-
`INSERT INTO tm_invitations
|
|
37
|
-
(org_id, invited_by_user_id, email, role, token_hash, code_encrypted, expires_at)
|
|
38
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
39
|
-
RETURNING *`,
|
|
40
|
-
[orgId, invitedByUserId, email, role, tokenHash, codeEncrypted, expiresAt]
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
const invitation = result.rows[0];
|
|
44
|
-
|
|
45
|
-
// Fetch org and inviter info for the email
|
|
46
|
-
const orgResult = await pool.query(`SELECT name FROM tm_organizations WHERE id = $1`, [orgId]);
|
|
47
|
-
const inviterUser = await adapter.getUserById(invitedByUserId);
|
|
48
|
-
const orgName = orgResult.rows[0]?.name ?? 'Unknown Organization';
|
|
49
|
-
const inviterName = inviterUser?.name ?? inviterUser?.email ?? 'A team member';
|
|
50
|
-
|
|
51
|
-
const magicLinkUrl = `${baseUrl}/join?token=${token}`;
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
await adapter.sendInviteEmail({
|
|
55
|
-
to: email,
|
|
56
|
-
orgName,
|
|
57
|
-
inviterName,
|
|
58
|
-
role,
|
|
59
|
-
magicLinkUrl,
|
|
60
|
-
code,
|
|
61
|
-
});
|
|
62
|
-
} catch (e) {
|
|
63
|
-
// Log but don't fail — invitation is created, email failure is non-fatal
|
|
64
|
-
adapter.logger.warn('[invitations] Failed to send invite email', {
|
|
65
|
-
invitationId: invitation.id,
|
|
66
|
-
error: (e as Error).message,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return { invitation, token, code };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function acceptInvitationByToken(
|
|
74
|
-
pool: Pool,
|
|
75
|
-
adapter: ServerModuleAdapter,
|
|
76
|
-
{ token, acceptingUserId }: { token: string; acceptingUserId?: number }
|
|
77
|
-
): Promise<{ orgId: number; role: OrgRole }> {
|
|
78
|
-
const tokenHash = sha256(token);
|
|
79
|
-
const client = await pool.connect();
|
|
80
|
-
try {
|
|
81
|
-
await client.query('BEGIN');
|
|
82
|
-
|
|
83
|
-
const result = await client.query<TmInvitation>(
|
|
84
|
-
`SELECT * FROM tm_invitations
|
|
85
|
-
WHERE token_hash = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()
|
|
86
|
-
FOR UPDATE`,
|
|
87
|
-
[tokenHash]
|
|
88
|
-
);
|
|
89
|
-
if (result.rows.length === 0) {
|
|
90
|
-
throw new Error('Invitation not found, expired, or already used');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const invitation = result.rows[0];
|
|
94
|
-
let userId = acceptingUserId;
|
|
95
|
-
|
|
96
|
-
if (!userId) {
|
|
97
|
-
// Try to find existing user by email, or create from invite
|
|
98
|
-
const existingUser = await adapter.findUserByEmail(invitation.email);
|
|
99
|
-
if (existingUser) {
|
|
100
|
-
userId = existingUser.id;
|
|
101
|
-
} else {
|
|
102
|
-
const newUser = await adapter.createUserFromInvite({
|
|
103
|
-
email: invitation.email,
|
|
104
|
-
orgId: invitation.org_id,
|
|
105
|
-
role: invitation.role,
|
|
106
|
-
});
|
|
107
|
-
userId = newUser.id;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Check if user is already in the org (sub-A: org switch)
|
|
112
|
-
const existingMembership = await client.query(
|
|
113
|
-
`SELECT id, role FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
114
|
-
[invitation.org_id, userId]
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
if (existingMembership.rows.length > 0) {
|
|
118
|
-
// Already a member — mark invite accepted and return
|
|
119
|
-
await client.query(
|
|
120
|
-
`UPDATE tm_invitations SET accepted_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
|
121
|
-
[invitation.id]
|
|
122
|
-
);
|
|
123
|
-
await client.query('COMMIT');
|
|
124
|
-
return { orgId: invitation.org_id, role: existingMembership.rows[0].role };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Add member to org
|
|
128
|
-
await client.query(
|
|
129
|
-
`INSERT INTO tm_memberships (org_id, user_id, role, joined_at)
|
|
130
|
-
VALUES ($1, $2, $3, NOW())
|
|
131
|
-
ON CONFLICT (org_id, user_id) WHERE removed_at IS NULL
|
|
132
|
-
DO UPDATE SET role = EXCLUDED.role, removed_at = NULL, removed_by_user_id = NULL,
|
|
133
|
-
removal_reason = NULL, updated_at = NOW()`,
|
|
134
|
-
[invitation.org_id, userId, invitation.role]
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
// Mark invitation as accepted
|
|
138
|
-
await client.query(
|
|
139
|
-
`UPDATE tm_invitations SET accepted_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
|
140
|
-
[invitation.id]
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
await client.query('COMMIT');
|
|
144
|
-
return { orgId: invitation.org_id, role: invitation.role };
|
|
145
|
-
} catch (e) {
|
|
146
|
-
await client.query('ROLLBACK');
|
|
147
|
-
throw e;
|
|
148
|
-
} finally {
|
|
149
|
-
client.release();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function acceptInvitationByCode(
|
|
154
|
-
pool: Pool,
|
|
155
|
-
adapter: ServerModuleAdapter,
|
|
156
|
-
{ email, code, acceptingUserId }: { email: string; code: string; acceptingUserId?: number }
|
|
157
|
-
): Promise<{ orgId: number; role: OrgRole }> {
|
|
158
|
-
// Find active invitations for this email
|
|
159
|
-
const result = await pool.query<TmInvitation>(
|
|
160
|
-
`SELECT * FROM tm_invitations
|
|
161
|
-
WHERE email = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()
|
|
162
|
-
ORDER BY created_at DESC
|
|
163
|
-
LIMIT 10`,
|
|
164
|
-
[email]
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
if (result.rows.length === 0) {
|
|
168
|
-
throw new Error('No valid invitation found for this email address');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Find the one with matching code
|
|
172
|
-
let matchedInvitation: TmInvitation | null = null;
|
|
173
|
-
for (const inv of result.rows) {
|
|
174
|
-
try {
|
|
175
|
-
const decryptedCode = decrypt(inv.code_encrypted);
|
|
176
|
-
if (decryptedCode === code) {
|
|
177
|
-
matchedInvitation = inv;
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
} catch {
|
|
181
|
-
// Skip invitations with corrupted codes
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (!matchedInvitation) {
|
|
186
|
-
throw new Error('Invalid invitation code');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Delegate to token-based accept with the matched invitation
|
|
190
|
-
// Temporarily update token so we can use acceptInvitationByToken logic
|
|
191
|
-
const client = await pool.connect();
|
|
192
|
-
try {
|
|
193
|
-
await client.query('BEGIN');
|
|
194
|
-
|
|
195
|
-
let userId = acceptingUserId;
|
|
196
|
-
if (!userId) {
|
|
197
|
-
const existingUser = await adapter.findUserByEmail(email);
|
|
198
|
-
if (existingUser) {
|
|
199
|
-
userId = existingUser.id;
|
|
200
|
-
} else {
|
|
201
|
-
const newUser = await adapter.createUserFromInvite({
|
|
202
|
-
email,
|
|
203
|
-
orgId: matchedInvitation.org_id,
|
|
204
|
-
role: matchedInvitation.role,
|
|
205
|
-
});
|
|
206
|
-
userId = newUser.id;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
await client.query(
|
|
211
|
-
`INSERT INTO tm_memberships (org_id, user_id, role, joined_at)
|
|
212
|
-
VALUES ($1, $2, $3, NOW())
|
|
213
|
-
ON CONFLICT (org_id, user_id) WHERE removed_at IS NULL
|
|
214
|
-
DO UPDATE SET role = EXCLUDED.role, removed_at = NULL, removed_by_user_id = NULL,
|
|
215
|
-
removal_reason = NULL, updated_at = NOW()`,
|
|
216
|
-
[matchedInvitation.org_id, userId, matchedInvitation.role]
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
await client.query(
|
|
220
|
-
`UPDATE tm_invitations SET accepted_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
|
221
|
-
[matchedInvitation.id]
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
await client.query('COMMIT');
|
|
225
|
-
return { orgId: matchedInvitation.org_id, role: matchedInvitation.role };
|
|
226
|
-
} catch (e) {
|
|
227
|
-
await client.query('ROLLBACK');
|
|
228
|
-
throw e;
|
|
229
|
-
} finally {
|
|
230
|
-
client.release();
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export async function revokeInvitation(
|
|
235
|
-
pool: Pool,
|
|
236
|
-
{ invitationId, revokedByUserId }: { invitationId: number; revokedByUserId: number }
|
|
237
|
-
): Promise<void> {
|
|
238
|
-
const result = await pool.query(
|
|
239
|
-
`UPDATE tm_invitations
|
|
240
|
-
SET revoked_at = NOW(), revoked_by_user_id = $1, updated_at = NOW()
|
|
241
|
-
WHERE id = $2 AND revoked_at IS NULL AND accepted_at IS NULL`,
|
|
242
|
-
[revokedByUserId, invitationId]
|
|
243
|
-
);
|
|
244
|
-
if ((result.rowCount ?? 0) === 0) {
|
|
245
|
-
throw new Error('Invitation not found, already accepted, or already revoked');
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export async function resendInvitation(
|
|
250
|
-
pool: Pool,
|
|
251
|
-
adapter: ServerModuleAdapter,
|
|
252
|
-
{ invitationId, baseUrl }: { invitationId: number; baseUrl: string }
|
|
253
|
-
): Promise<void> {
|
|
254
|
-
const result = await pool.query<TmInvitation>(
|
|
255
|
-
`SELECT * FROM tm_invitations WHERE id = $1 AND revoked_at IS NULL AND accepted_at IS NULL`,
|
|
256
|
-
[invitationId]
|
|
257
|
-
);
|
|
258
|
-
if (result.rows.length === 0) {
|
|
259
|
-
throw new Error('Invitation not found, accepted, or revoked');
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const invitation = result.rows[0];
|
|
263
|
-
const newToken = generateToken(32);
|
|
264
|
-
const newCode = generateSixDigitCode();
|
|
265
|
-
const newTokenHash = sha256(newToken);
|
|
266
|
-
const newCodeEncrypted = encrypt(newCode);
|
|
267
|
-
const newExpiresAt = new Date(Date.now() + INVITATION_EXPIRY_HOURS * 60 * 60 * 1000);
|
|
268
|
-
|
|
269
|
-
await pool.query(
|
|
270
|
-
`UPDATE tm_invitations
|
|
271
|
-
SET token_hash = $1, code_encrypted = $2, expires_at = $3,
|
|
272
|
-
resent_count = resent_count + 1, updated_at = NOW()
|
|
273
|
-
WHERE id = $4`,
|
|
274
|
-
[newTokenHash, newCodeEncrypted, newExpiresAt, invitationId]
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
const orgResult = await pool.query(`SELECT name FROM tm_organizations WHERE id = $1`, [invitation.org_id]);
|
|
278
|
-
const inviterUser = await adapter.getUserById(invitation.invited_by_user_id);
|
|
279
|
-
const orgName = orgResult.rows[0]?.name ?? 'Unknown Organization';
|
|
280
|
-
const inviterName = inviterUser?.name ?? inviterUser?.email ?? 'A team member';
|
|
281
|
-
const magicLinkUrl = `${baseUrl}/join?token=${newToken}`;
|
|
282
|
-
|
|
283
|
-
await adapter.sendInviteEmail({
|
|
284
|
-
to: invitation.email,
|
|
285
|
-
orgName,
|
|
286
|
-
inviterName,
|
|
287
|
-
role: invitation.role,
|
|
288
|
-
magicLinkUrl,
|
|
289
|
-
code: newCode,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export async function listPendingInvitations(pool: Pool, orgId: number): Promise<TmInvitation[]> {
|
|
294
|
-
const result = await pool.query<TmInvitation>(
|
|
295
|
-
`SELECT * FROM tm_invitations
|
|
296
|
-
WHERE org_id = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()
|
|
297
|
-
ORDER BY created_at DESC`,
|
|
298
|
-
[orgId]
|
|
299
|
-
);
|
|
300
|
-
return result.rows;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export async function getInvitationWithDecryptedCode(
|
|
304
|
-
pool: Pool,
|
|
305
|
-
invitationId: number
|
|
306
|
-
): Promise<TmInvitation & { code: string }> {
|
|
307
|
-
const result = await pool.query<TmInvitation>(
|
|
308
|
-
`SELECT * FROM tm_invitations WHERE id = $1`,
|
|
309
|
-
[invitationId]
|
|
310
|
-
);
|
|
311
|
-
if (result.rows.length === 0) throw new Error('Invitation not found');
|
|
312
|
-
const inv = result.rows[0];
|
|
313
|
-
const code = decrypt(inv.code_encrypted);
|
|
314
|
-
return { ...inv, code };
|
|
315
|
-
}
|
|
316
|
-
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import type { Pool } from 'pg';
|
|
2
|
-
import type { TmMembership, OrgRole } from '../types.js';
|
|
3
|
-
import { ROLE_HIERARCHY } from '../types.js';
|
|
4
|
-
|
|
5
|
-
export async function getMembership(
|
|
6
|
-
pool: Pool,
|
|
7
|
-
orgId: number,
|
|
8
|
-
userId: number
|
|
9
|
-
): Promise<TmMembership | null> {
|
|
10
|
-
const result = await pool.query<TmMembership>(
|
|
11
|
-
`SELECT * FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
12
|
-
[orgId, userId]
|
|
13
|
-
);
|
|
14
|
-
return result.rows[0] ?? null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export async function addMember(
|
|
18
|
-
pool: Pool,
|
|
19
|
-
{ orgId, userId, role }: { orgId: number; userId: number; role: OrgRole }
|
|
20
|
-
): Promise<TmMembership> {
|
|
21
|
-
const result = await pool.query<TmMembership>(
|
|
22
|
-
`INSERT INTO tm_memberships (org_id, user_id, role, joined_at)
|
|
23
|
-
VALUES ($1, $2, $3, NOW())
|
|
24
|
-
ON CONFLICT (org_id, user_id)
|
|
25
|
-
DO UPDATE SET role = EXCLUDED.role, removed_at = NULL, removed_by_user_id = NULL,
|
|
26
|
-
removal_reason = NULL, updated_at = NOW()
|
|
27
|
-
RETURNING *`,
|
|
28
|
-
[orgId, userId, role]
|
|
29
|
-
);
|
|
30
|
-
return result.rows[0];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function removeMember(
|
|
34
|
-
pool: Pool,
|
|
35
|
-
{
|
|
36
|
-
orgId,
|
|
37
|
-
userId,
|
|
38
|
-
removedByUserId,
|
|
39
|
-
reason,
|
|
40
|
-
}: { orgId: number; userId: number; removedByUserId: number; reason?: string }
|
|
41
|
-
): Promise<void> {
|
|
42
|
-
const client = await pool.connect();
|
|
43
|
-
try {
|
|
44
|
-
await client.query('BEGIN');
|
|
45
|
-
|
|
46
|
-
const memberResult = await client.query(
|
|
47
|
-
`UPDATE tm_memberships
|
|
48
|
-
SET removed_at = NOW(), removed_by_user_id = $1, removal_reason = $2, updated_at = NOW()
|
|
49
|
-
WHERE org_id = $3 AND user_id = $4 AND removed_at IS NULL`,
|
|
50
|
-
[removedByUserId, reason ?? null, orgId, userId]
|
|
51
|
-
);
|
|
52
|
-
if ((memberResult.rowCount ?? 0) === 0) {
|
|
53
|
-
throw new Error('Membership not found or already removed');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
await client.query(
|
|
57
|
-
`UPDATE tm_invitations
|
|
58
|
-
SET revoked_at = NOW(), revoked_by_user_id = $1, updated_at = NOW()
|
|
59
|
-
WHERE org_id = $2 AND invited_by_user_id = $3 AND revoked_at IS NULL AND accepted_at IS NULL`,
|
|
60
|
-
[removedByUserId, orgId, userId]
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
await client.query('COMMIT');
|
|
64
|
-
} catch (e) {
|
|
65
|
-
await client.query('ROLLBACK');
|
|
66
|
-
throw e;
|
|
67
|
-
} finally {
|
|
68
|
-
client.release();
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export async function changeRole(
|
|
73
|
-
pool: Pool,
|
|
74
|
-
{
|
|
75
|
-
orgId,
|
|
76
|
-
userId,
|
|
77
|
-
newRole,
|
|
78
|
-
changedByUserId: _changedByUserId,
|
|
79
|
-
}: { orgId: number; userId: number; newRole: OrgRole; changedByUserId: number }
|
|
80
|
-
): Promise<TmMembership> {
|
|
81
|
-
const result = await pool.query<TmMembership>(
|
|
82
|
-
`UPDATE tm_memberships
|
|
83
|
-
SET role = $1, updated_at = NOW()
|
|
84
|
-
WHERE org_id = $2 AND user_id = $3 AND removed_at IS NULL
|
|
85
|
-
RETURNING *`,
|
|
86
|
-
[newRole, orgId, userId]
|
|
87
|
-
);
|
|
88
|
-
if (result.rows.length === 0) throw new Error('Membership not found');
|
|
89
|
-
return result.rows[0];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function validateRoleChange(
|
|
93
|
-
pool: Pool,
|
|
94
|
-
{
|
|
95
|
-
orgId,
|
|
96
|
-
actorRole,
|
|
97
|
-
targetUserId,
|
|
98
|
-
newRole,
|
|
99
|
-
}: { orgId: number; actorRole: OrgRole; targetUserId: number; newRole: OrgRole }
|
|
100
|
-
): Promise<void> {
|
|
101
|
-
if (newRole === 'owner') {
|
|
102
|
-
throw new Error('Cannot assign owner role directly. Use the ownership transfer flow.');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (ROLE_HIERARCHY[actorRole] < ROLE_HIERARCHY['admin']) {
|
|
106
|
-
throw new Error('Requires admin role or higher to change member roles');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const result = await pool.query<TmMembership>(
|
|
110
|
-
`SELECT role FROM tm_memberships WHERE org_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
|
111
|
-
[orgId, targetUserId]
|
|
112
|
-
);
|
|
113
|
-
if (result.rows.length === 0) throw new Error('Target user is not a member of this organization');
|
|
114
|
-
|
|
115
|
-
const targetCurrentRole = result.rows[0].role;
|
|
116
|
-
|
|
117
|
-
// Protect the owner role — must use ownership transfer flow to change it
|
|
118
|
-
if (targetCurrentRole === 'owner') {
|
|
119
|
-
throw new Error('Cannot change the owner role. Use the ownership transfer flow to transfer ownership first.');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (actorRole !== 'owner' && ROLE_HIERARCHY[targetCurrentRole] >= ROLE_HIERARCHY[actorRole]) {
|
|
123
|
-
throw new Error('You cannot change the role of a member with equal or higher permissions');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (actorRole !== 'owner' && ROLE_HIERARCHY[newRole] >= ROLE_HIERARCHY[actorRole]) {
|
|
127
|
-
throw new Error('You cannot assign a role equal to or higher than your own');
|
|
128
|
-
}
|
|
129
|
-
}
|