@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
package/src/server/types.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import type { Request } from 'express';
|
|
2
|
-
|
|
3
|
-
export type OrgRole = 'owner' | 'admin' | 'member' | 'viewer';
|
|
4
|
-
export type AuditActorType = 'user' | 'super_admin';
|
|
5
|
-
export type TransferStatus = 'pending' | 'accepted' | 'cancelled' | 'expired';
|
|
6
|
-
|
|
7
|
-
export interface TmOrganization {
|
|
8
|
-
id: number;
|
|
9
|
-
name: string;
|
|
10
|
-
slug: string;
|
|
11
|
-
owner_user_id: number;
|
|
12
|
-
settings: Record<string, unknown>;
|
|
13
|
-
deleted_at: Date | null;
|
|
14
|
-
delete_scheduled_for: Date | null;
|
|
15
|
-
deleted_by_user_id: number | null;
|
|
16
|
-
created_at: Date;
|
|
17
|
-
updated_at: Date;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface TmMembership {
|
|
21
|
-
id: number;
|
|
22
|
-
org_id: number;
|
|
23
|
-
user_id: number;
|
|
24
|
-
role: OrgRole;
|
|
25
|
-
joined_at: Date;
|
|
26
|
-
removed_at: Date | null;
|
|
27
|
-
removed_by_user_id: number | null;
|
|
28
|
-
removal_reason: string | null;
|
|
29
|
-
created_at: Date;
|
|
30
|
-
updated_at: Date;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface TmInvitation {
|
|
34
|
-
id: number;
|
|
35
|
-
org_id: number;
|
|
36
|
-
invited_by_user_id: number;
|
|
37
|
-
email: string;
|
|
38
|
-
role: OrgRole;
|
|
39
|
-
token_hash: string;
|
|
40
|
-
code_encrypted: string;
|
|
41
|
-
expires_at: Date;
|
|
42
|
-
accepted_at: Date | null;
|
|
43
|
-
revoked_at: Date | null;
|
|
44
|
-
revoked_by_user_id: number | null;
|
|
45
|
-
resent_count: number;
|
|
46
|
-
created_at: Date;
|
|
47
|
-
updated_at: Date;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface TmAuditEvent {
|
|
51
|
-
id: string;
|
|
52
|
-
org_id: number | null;
|
|
53
|
-
actor_user_id: number | null;
|
|
54
|
-
actor_type: AuditActorType;
|
|
55
|
-
action: string;
|
|
56
|
-
target_type: string | null;
|
|
57
|
-
target_id: string | null;
|
|
58
|
-
before_state: Record<string, unknown> | null;
|
|
59
|
-
after_state: Record<string, unknown> | null;
|
|
60
|
-
ip: string | null;
|
|
61
|
-
user_agent: string | null;
|
|
62
|
-
reason: string | null;
|
|
63
|
-
created_at: Date;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface TmOwnershipTransfer {
|
|
67
|
-
id: number;
|
|
68
|
-
org_id: number;
|
|
69
|
-
from_user_id: number;
|
|
70
|
-
to_user_id: number;
|
|
71
|
-
status: TransferStatus;
|
|
72
|
-
initiated_at: Date;
|
|
73
|
-
accepted_at: Date | null;
|
|
74
|
-
cancelled_at: Date | null;
|
|
75
|
-
cancelled_by_user_id: number | null;
|
|
76
|
-
expires_at: Date;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface TmPasswordResetRequest {
|
|
80
|
-
id: number;
|
|
81
|
-
user_id: number;
|
|
82
|
-
token_hash: string;
|
|
83
|
-
expires_at: Date;
|
|
84
|
-
used_at: Date | null;
|
|
85
|
-
created_at: Date;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface ServerModuleAdapter {
|
|
89
|
-
// v0.0.1
|
|
90
|
-
getCurrentUserId(req: Request): Promise<number | null>;
|
|
91
|
-
getOrganizationIdForUser(userId: number): Promise<number | null>;
|
|
92
|
-
isUserOrgAdmin(userId: number, orgId: number): Promise<boolean>;
|
|
93
|
-
logger: {
|
|
94
|
-
info(message: string, meta?: Record<string, unknown>): void;
|
|
95
|
-
warn(message: string, meta?: Record<string, unknown>): void;
|
|
96
|
-
error(message: string, meta?: Record<string, unknown>): void;
|
|
97
|
-
};
|
|
98
|
-
// v0.1.0 additions
|
|
99
|
-
getUserById(userId: number): Promise<{ id: number; email: string; name?: string } | null>;
|
|
100
|
-
getUsersByIds(userIds: number[]): Promise<Array<{ id: number; email: string; name?: string }>>;
|
|
101
|
-
findUserByEmail(email: string): Promise<{ id: number; email: string } | null>;
|
|
102
|
-
createUserFromInvite(data: { email: string; orgId: number; role: OrgRole }): Promise<{ id: number; email: string }>;
|
|
103
|
-
setUserPassword(userId: number, passwordHash: string): Promise<void>;
|
|
104
|
-
hashPassword(plaintext: string): Promise<string>;
|
|
105
|
-
verifyPassword(plaintext: string, hash: string): Promise<boolean>;
|
|
106
|
-
invalidateAllUserSessions(userId: number): Promise<void>;
|
|
107
|
-
sendInviteEmail(data: { to: string; orgName: string; inviterName: string; role: OrgRole; magicLinkUrl: string; code: string }): Promise<void>;
|
|
108
|
-
sendOwnershipTransferEmail(data: { to: string; orgName: string; fromName: string; transferUrl: string }): Promise<void>;
|
|
109
|
-
sendEmailChangeVerification(data: { to: string; verifyUrl: string }): Promise<void>;
|
|
110
|
-
sendEmailChangeOldNotice(data: { to: string; newEmail: string; cancelUrl: string }): Promise<void>;
|
|
111
|
-
sendEmailChangedFinalNotice(data: { to: string; oldEmail: string; newEmail: string }): Promise<void>;
|
|
112
|
-
sendPasswordResetEmail(data: { to: string; resetUrl: string }): Promise<void>;
|
|
113
|
-
sendOrgDeletionNotice(data: { to: string; orgName: string; scheduledFor: Date }): Promise<void>;
|
|
114
|
-
emitNotification(data: { userId: number; type: string; payload: Record<string, unknown> }): Promise<void>;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface TeamManagementFeatureFlags {
|
|
118
|
-
enableInvites?: boolean;
|
|
119
|
-
enableAuditLog?: boolean;
|
|
120
|
-
enableOwnershipTransfer?: boolean;
|
|
121
|
-
enableEmailChange?: boolean;
|
|
122
|
-
enablePasswordReset?: boolean;
|
|
123
|
-
enableSuperAdmin?: boolean;
|
|
124
|
-
enableSharedAccess?: boolean;
|
|
125
|
-
enableHardDelete?: boolean;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface TeamManagementConfig {
|
|
129
|
-
featureFlags?: TeamManagementFeatureFlags;
|
|
130
|
-
baseUrl?: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface TeamManagementServerModule {
|
|
134
|
-
router: import('express').Router;
|
|
135
|
-
runMigrations(): Promise<{ applied: string[]; skipped: string[] }>;
|
|
136
|
-
/** Alias for runMigrations — for test convenience */
|
|
137
|
-
migrate(): Promise<{ applied: string[]; skipped: string[] }>;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Role permission helpers — values are spaced by 10 so future roles can be inserted
|
|
141
|
-
export const ROLE_HIERARCHY: Record<OrgRole, number> = { viewer: 10, member: 20, admin: 30, owner: 40 };
|
|
142
|
-
|
|
143
|
-
export function roleAtLeast(userRole: OrgRole, required: OrgRole): boolean {
|
|
144
|
-
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[required];
|
|
145
|
-
}
|
package/src/shared/types.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared types used by both server and client sides of team-management.
|
|
3
|
-
* No Node.js or browser-specific APIs here.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/** A team member as returned by the module's public API. */
|
|
7
|
-
export interface TeamMember {
|
|
8
|
-
id: number;
|
|
9
|
-
userId: number;
|
|
10
|
-
organizationId: number;
|
|
11
|
-
role: 'owner' | 'admin' | 'member';
|
|
12
|
-
joinedAt: string; // ISO 8601
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** An invite as returned by the module's public API (stub shape). */
|
|
16
|
-
export interface TeamInvite {
|
|
17
|
-
id: number;
|
|
18
|
-
organizationId: number;
|
|
19
|
-
email: string;
|
|
20
|
-
role: 'admin' | 'member';
|
|
21
|
-
status: 'pending' | 'accepted' | 'expired';
|
|
22
|
-
createdAt: string; // ISO 8601
|
|
23
|
-
expiresAt: string; // ISO 8601
|
|
24
|
-
}
|
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import request from 'supertest';
|
|
4
|
-
import { Pool } from 'pg';
|
|
5
|
-
import { createServerModule } from '../../src/server/index.js';
|
|
6
|
-
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
7
|
-
|
|
8
|
-
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
9
|
-
|
|
10
|
-
let currentUserId: number | null = null;
|
|
11
|
-
let currentOrgId: number | null = null;
|
|
12
|
-
|
|
13
|
-
function extractToken(spy: ReturnType<typeof vi.fn>): string {
|
|
14
|
-
const callArg = spy.mock.calls[0]?.[0] as { magicLinkUrl?: string } | undefined;
|
|
15
|
-
const url = callArg?.magicLinkUrl ?? '';
|
|
16
|
-
const qs = url.includes('?') ? url.split('?')[1] : '';
|
|
17
|
-
return new URLSearchParams(qs).get('token') ?? '';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function extractEmailChangeToken(spy: ReturnType<typeof vi.fn>): string {
|
|
21
|
-
const callArg = spy.mock.calls[0]?.[0] as { verifyUrl?: string } | undefined;
|
|
22
|
-
const url = callArg?.verifyUrl ?? '';
|
|
23
|
-
const qs = url.includes('?') ? url.split('?')[1] : '';
|
|
24
|
-
return new URLSearchParams(qs).get('token') ?? '';
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const sendInviteEmail = vi.fn(async () => {});
|
|
28
|
-
const sendEmailChangeVerification = vi.fn(async () => {});
|
|
29
|
-
|
|
30
|
-
const testAdapter: ServerModuleAdapter = {
|
|
31
|
-
getCurrentUserId: async () => currentUserId,
|
|
32
|
-
getOrganizationIdForUser: async () => currentOrgId,
|
|
33
|
-
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
34
|
-
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
35
|
-
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
36
|
-
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
37
|
-
findUserByEmail: async (email) => {
|
|
38
|
-
const m = email.match(/^u(\d+)@test\.com$/);
|
|
39
|
-
return m ? { id: parseInt(m[1]), email } : null;
|
|
40
|
-
},
|
|
41
|
-
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
42
|
-
setUserPassword: async () => {},
|
|
43
|
-
hashPassword: async (p) => `h:${p}`,
|
|
44
|
-
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
45
|
-
invalidateAllUserSessions: async () => {},
|
|
46
|
-
sendInviteEmail,
|
|
47
|
-
sendOwnershipTransferEmail: async () => {},
|
|
48
|
-
sendEmailChangeVerification,
|
|
49
|
-
sendEmailChangeOldNotice: async () => {},
|
|
50
|
-
sendEmailChangedFinalNotice: async () => {},
|
|
51
|
-
sendPasswordResetEmail: async () => {},
|
|
52
|
-
sendOrgDeletionNotice: async () => {},
|
|
53
|
-
emitNotification: async () => {},
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
async function cleanAll(pool: Pool) {
|
|
57
|
-
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
58
|
-
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
59
|
-
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function seedOrg(pool: Pool, orgId = 1) {
|
|
63
|
-
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
64
|
-
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
65
|
-
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
66
|
-
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
67
|
-
ON CONFLICT DO NOTHING`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function getLatestAuditEvent(pool: Pool, action: string) {
|
|
71
|
-
const res = await pool.query(
|
|
72
|
-
`SELECT * FROM tm_audit_events WHERE action = $1 ORDER BY created_at DESC LIMIT 1`,
|
|
73
|
-
[action]
|
|
74
|
-
);
|
|
75
|
-
return res.rows[0] ?? null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
describeWithDb('audit events fire for all 13 event types', () => {
|
|
79
|
-
let pool: Pool;
|
|
80
|
-
let app: express.Express;
|
|
81
|
-
|
|
82
|
-
beforeAll(async () => {
|
|
83
|
-
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
84
|
-
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
85
|
-
app = express();
|
|
86
|
-
app.use(express.json());
|
|
87
|
-
app.use(mod.router);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
afterAll(async () => {
|
|
91
|
-
await pool.end();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
beforeEach(async () => {
|
|
95
|
-
await cleanAll(pool);
|
|
96
|
-
sendInviteEmail.mockClear();
|
|
97
|
-
sendEmailChangeVerification.mockClear();
|
|
98
|
-
currentUserId = 1;
|
|
99
|
-
currentOrgId = 1;
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('org.created fires when org is created', async () => {
|
|
103
|
-
const res = await request(app)
|
|
104
|
-
.post('/orgs')
|
|
105
|
-
.send({ name: 'Audit Org', slug: 'audit-org' });
|
|
106
|
-
expect(res.status).toBe(201);
|
|
107
|
-
|
|
108
|
-
const event = await getLatestAuditEvent(pool, 'org.created');
|
|
109
|
-
expect(event).not.toBeNull();
|
|
110
|
-
expect(event.action).toBe('org.created');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('org.settings.updated fires on PATCH /orgs/:id', async () => {
|
|
114
|
-
await seedOrg(pool);
|
|
115
|
-
|
|
116
|
-
const res = await request(app)
|
|
117
|
-
.patch('/orgs/1')
|
|
118
|
-
.send({ name: 'Updated Name' });
|
|
119
|
-
expect(res.status).toBeLessThan(500);
|
|
120
|
-
|
|
121
|
-
const event = await getLatestAuditEvent(pool, 'org.settings.updated');
|
|
122
|
-
expect(event).not.toBeNull();
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('org.deleted fires on DELETE /orgs/:id', async () => {
|
|
126
|
-
await seedOrg(pool);
|
|
127
|
-
|
|
128
|
-
const res = await request(app)
|
|
129
|
-
.delete('/orgs/1')
|
|
130
|
-
.send({ confirmName: 'Test Org 1' });
|
|
131
|
-
expect(res.status).toBeLessThan(500);
|
|
132
|
-
|
|
133
|
-
const event = await getLatestAuditEvent(pool, 'org.deleted');
|
|
134
|
-
expect(event).not.toBeNull();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('member.invited fires on POST /orgs/:id/invitations', async () => {
|
|
138
|
-
await seedOrg(pool);
|
|
139
|
-
|
|
140
|
-
const res = await request(app)
|
|
141
|
-
.post('/orgs/1/invitations')
|
|
142
|
-
.send({ email: 'newuser@example.com', role: 'member' });
|
|
143
|
-
expect(res.status).toBeLessThan(500);
|
|
144
|
-
|
|
145
|
-
const event = await getLatestAuditEvent(pool, 'member.invited');
|
|
146
|
-
expect(event).not.toBeNull();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('member.invite_accepted fires when invitation is accepted', async () => {
|
|
150
|
-
await seedOrg(pool);
|
|
151
|
-
|
|
152
|
-
const invRes = await request(app)
|
|
153
|
-
.post('/orgs/1/invitations')
|
|
154
|
-
.send({ email: 'u99@test.com', role: 'member' });
|
|
155
|
-
expect(invRes.status).toBeLessThan(500);
|
|
156
|
-
|
|
157
|
-
const token = extractToken(sendInviteEmail);
|
|
158
|
-
if (!token) return; // Can't test without a token
|
|
159
|
-
|
|
160
|
-
currentUserId = 99;
|
|
161
|
-
currentOrgId = null;
|
|
162
|
-
const acceptRes = await request(app)
|
|
163
|
-
.post('/invitations/accept/token')
|
|
164
|
-
.send({ token });
|
|
165
|
-
expect(acceptRes.status).toBeLessThan(500);
|
|
166
|
-
|
|
167
|
-
const event = await getLatestAuditEvent(pool, 'member.invite_accepted');
|
|
168
|
-
expect(event).not.toBeNull();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('member.invite_revoked fires on DELETE /orgs/:id/invitations/:invId', async () => {
|
|
172
|
-
await seedOrg(pool);
|
|
173
|
-
|
|
174
|
-
const invRes = await request(app)
|
|
175
|
-
.post('/orgs/1/invitations')
|
|
176
|
-
.send({ email: 'revoke@example.com', role: 'member' });
|
|
177
|
-
expect(invRes.status).toBeLessThan(500);
|
|
178
|
-
|
|
179
|
-
const inv = await pool.query(`SELECT id FROM tm_invitations ORDER BY created_at DESC LIMIT 1`);
|
|
180
|
-
if (!inv.rows.length) return;
|
|
181
|
-
const invId = inv.rows[0].id;
|
|
182
|
-
|
|
183
|
-
const revokeRes = await request(app).delete(`/orgs/1/invitations/${invId}`);
|
|
184
|
-
expect(revokeRes.status).toBeLessThan(500);
|
|
185
|
-
|
|
186
|
-
const event = await getLatestAuditEvent(pool, 'member.invite_revoked');
|
|
187
|
-
expect(event).not.toBeNull();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('member.removed fires on DELETE /orgs/:id/members/:userId', async () => {
|
|
191
|
-
await seedOrg(pool);
|
|
192
|
-
|
|
193
|
-
const res = await request(app).delete('/orgs/1/members/3');
|
|
194
|
-
expect(res.status).toBeLessThan(500);
|
|
195
|
-
|
|
196
|
-
const event = await getLatestAuditEvent(pool, 'member.removed');
|
|
197
|
-
expect(event).not.toBeNull();
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('member.role_changed fires on PATCH /orgs/:id/members/:userId/role', async () => {
|
|
201
|
-
await seedOrg(pool);
|
|
202
|
-
|
|
203
|
-
const res = await request(app)
|
|
204
|
-
.patch('/orgs/1/members/3/role')
|
|
205
|
-
.send({ role: 'admin' });
|
|
206
|
-
expect(res.status).toBeLessThan(500);
|
|
207
|
-
|
|
208
|
-
const event = await getLatestAuditEvent(pool, 'member.role_changed');
|
|
209
|
-
expect(event).not.toBeNull();
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('ownership.transfer_initiated fires on POST /orgs/:id/transfer', async () => {
|
|
213
|
-
await seedOrg(pool);
|
|
214
|
-
|
|
215
|
-
const res = await request(app)
|
|
216
|
-
.post('/orgs/1/transfer')
|
|
217
|
-
.send({ toUserId: 2 });
|
|
218
|
-
expect(res.status).toBeLessThan(500);
|
|
219
|
-
|
|
220
|
-
const event = await getLatestAuditEvent(pool, 'ownership.transfer_initiated');
|
|
221
|
-
expect(event).not.toBeNull();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('ownership.transfer_accepted fires on POST /orgs/:id/transfer/accept', async () => {
|
|
225
|
-
await seedOrg(pool);
|
|
226
|
-
|
|
227
|
-
const initRes = await request(app)
|
|
228
|
-
.post('/orgs/1/transfer')
|
|
229
|
-
.send({ toUserId: 2 });
|
|
230
|
-
expect(initRes.status).toBeLessThan(500);
|
|
231
|
-
|
|
232
|
-
currentUserId = 2;
|
|
233
|
-
const acceptRes = await request(app)
|
|
234
|
-
.post('/orgs/1/transfer/accept')
|
|
235
|
-
.send({});
|
|
236
|
-
expect(acceptRes.status).toBeLessThan(500);
|
|
237
|
-
|
|
238
|
-
const event = await getLatestAuditEvent(pool, 'ownership.transfer_accepted');
|
|
239
|
-
expect(event).not.toBeNull();
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('ownership.transfer_cancelled fires on DELETE /orgs/:id/transfer', async () => {
|
|
243
|
-
await seedOrg(pool);
|
|
244
|
-
|
|
245
|
-
await request(app)
|
|
246
|
-
.post('/orgs/1/transfer')
|
|
247
|
-
.send({ toUserId: 2 });
|
|
248
|
-
|
|
249
|
-
const cancelRes = await request(app)
|
|
250
|
-
.delete('/orgs/1/transfer');
|
|
251
|
-
expect(cancelRes.status).toBeLessThan(500);
|
|
252
|
-
|
|
253
|
-
const event = await getLatestAuditEvent(pool, 'ownership.transfer_cancelled');
|
|
254
|
-
expect(event).not.toBeNull();
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('email.change_requested fires on POST /me/email-change', async () => {
|
|
258
|
-
await seedOrg(pool);
|
|
259
|
-
|
|
260
|
-
const res = await request(app)
|
|
261
|
-
.post('/me/email-change')
|
|
262
|
-
.send({ newEmail: 'newemail@test.com', currentPassword: 'pass' });
|
|
263
|
-
expect(res.status).toBeLessThan(500);
|
|
264
|
-
|
|
265
|
-
const event = await getLatestAuditEvent(pool, 'email.change_requested');
|
|
266
|
-
expect(event).not.toBeNull();
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('email.change_completed fires when email change is verified', async () => {
|
|
270
|
-
await seedOrg(pool);
|
|
271
|
-
|
|
272
|
-
await request(app)
|
|
273
|
-
.post('/me/email-change')
|
|
274
|
-
.send({ newEmail: 'verified@test.com', currentPassword: 'pass' });
|
|
275
|
-
|
|
276
|
-
// Extract verify token from spy (service calls sendEmailChangeVerification({ to, verifyUrl }))
|
|
277
|
-
const verifyToken = extractEmailChangeToken(sendEmailChangeVerification);
|
|
278
|
-
if (!verifyToken) return; // skip if email change not triggered
|
|
279
|
-
|
|
280
|
-
currentUserId = null; // token-based route
|
|
281
|
-
const verifyRes = await request(app)
|
|
282
|
-
.get(`/me/email-change/verify?token=${verifyToken}`);
|
|
283
|
-
expect(verifyRes.status).toBeLessThan(500);
|
|
284
|
-
|
|
285
|
-
const event = await getLatestAuditEvent(pool, 'email.change_completed');
|
|
286
|
-
expect(event).not.toBeNull();
|
|
287
|
-
});
|
|
288
|
-
});
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
|
-
import express from 'express';
|
|
4
|
-
import request from 'supertest';
|
|
5
|
-
import { Pool } from 'pg';
|
|
6
|
-
import { createServerModule } from '../../src/server/index.js';
|
|
7
|
-
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
8
|
-
|
|
9
|
-
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
10
|
-
|
|
11
|
-
function sha256(s: string): string {
|
|
12
|
-
return createHash('sha256').update(s).digest('hex');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let currentUserId: number | null = null;
|
|
16
|
-
let currentOrgId: number | null = null;
|
|
17
|
-
|
|
18
|
-
const testAdapter: ServerModuleAdapter = {
|
|
19
|
-
getCurrentUserId: async () => currentUserId,
|
|
20
|
-
getOrganizationIdForUser: async () => currentOrgId,
|
|
21
|
-
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
22
|
-
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
23
|
-
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
24
|
-
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
25
|
-
findUserByEmail: async (email) => {
|
|
26
|
-
const m = email.match(/^u(\d+)@test\.com$/);
|
|
27
|
-
return m ? { id: parseInt(m[1]), email } : null;
|
|
28
|
-
},
|
|
29
|
-
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
30
|
-
setUserPassword: async () => {},
|
|
31
|
-
hashPassword: async (p) => `h:${p}`,
|
|
32
|
-
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
33
|
-
invalidateAllUserSessions: async () => {},
|
|
34
|
-
sendInviteEmail: async () => {},
|
|
35
|
-
sendOwnershipTransferEmail: async () => {},
|
|
36
|
-
sendEmailChangeVerification: async () => {},
|
|
37
|
-
sendEmailChangeOldNotice: async () => {},
|
|
38
|
-
sendEmailChangedFinalNotice: async () => {},
|
|
39
|
-
sendPasswordResetEmail: async () => {},
|
|
40
|
-
sendOrgDeletionNotice: async () => {},
|
|
41
|
-
emitNotification: async () => {},
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
async function cleanAll(pool: Pool) {
|
|
45
|
-
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
46
|
-
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
47
|
-
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function seedOrg(pool: Pool, orgId = 1) {
|
|
51
|
-
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
52
|
-
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
53
|
-
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
54
|
-
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
55
|
-
ON CONFLICT DO NOTHING`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
describeWithDb('cascade preview', () => {
|
|
59
|
-
let pool: Pool;
|
|
60
|
-
let app: express.Express;
|
|
61
|
-
|
|
62
|
-
beforeAll(async () => {
|
|
63
|
-
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
64
|
-
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
65
|
-
app = express();
|
|
66
|
-
app.use(express.json());
|
|
67
|
-
app.use(mod.router);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
afterAll(async () => {
|
|
71
|
-
await pool.end();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
beforeEach(async () => {
|
|
75
|
-
await cleanAll(pool);
|
|
76
|
-
currentUserId = 1;
|
|
77
|
-
currentOrgId = 1;
|
|
78
|
-
await seedOrg(pool);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('returns cascade preview for member who has sent invitations', async () => {
|
|
82
|
-
// Member 2 sends an invitation — insert directly with correct column names
|
|
83
|
-
await pool.query(
|
|
84
|
-
`INSERT INTO tm_invitations (org_id, invited_by_user_id, email, role, token_hash, code_encrypted, expires_at)
|
|
85
|
-
VALUES (1, 2, 'invited@example.com', 'member', $1, 'fake-enc-abc123', NOW() + INTERVAL '7 days')`,
|
|
86
|
-
[sha256('tok-abc123')]
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
currentUserId = 1; // admin calling on behalf
|
|
90
|
-
|
|
91
|
-
const res = await request(app)
|
|
92
|
-
.get('/orgs/1/members/2/cascade-preview');
|
|
93
|
-
|
|
94
|
-
expect(res.status).toBeLessThan(500);
|
|
95
|
-
expect(res.body).toBeDefined();
|
|
96
|
-
|
|
97
|
-
// Must include the membership being removed
|
|
98
|
-
const body = res.body;
|
|
99
|
-
expect(body).toHaveProperty('membership');
|
|
100
|
-
|
|
101
|
-
// Must include pending invitations they sent
|
|
102
|
-
expect(body).toHaveProperty('pendingInvitations');
|
|
103
|
-
const invitations = body.pendingInvitations as Array<{ invited_by_user_id?: number; invitedByUserId?: number }>;
|
|
104
|
-
expect(Array.isArray(invitations)).toBe(true);
|
|
105
|
-
expect(invitations.length).toBeGreaterThanOrEqual(1);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('returns cascade preview for member who has NOT sent invitations', async () => {
|
|
109
|
-
// Member 4 has no invitations
|
|
110
|
-
const res = await request(app)
|
|
111
|
-
.get('/orgs/1/members/4/cascade-preview');
|
|
112
|
-
|
|
113
|
-
expect(res.status).toBeLessThan(500);
|
|
114
|
-
expect(res.body).toBeDefined();
|
|
115
|
-
|
|
116
|
-
const body = res.body;
|
|
117
|
-
expect(body).toHaveProperty('membership');
|
|
118
|
-
expect(body).toHaveProperty('pendingInvitations');
|
|
119
|
-
|
|
120
|
-
const invitations = body.pendingInvitations as unknown[];
|
|
121
|
-
expect(Array.isArray(invitations)).toBe(true);
|
|
122
|
-
expect(invitations.length).toBe(0);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('returns 404 for non-existent member', async () => {
|
|
126
|
-
const res = await request(app)
|
|
127
|
-
.get('/orgs/1/members/999/cascade-preview');
|
|
128
|
-
|
|
129
|
-
expect(res.status).toBe(404);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('returns 403 for non-admin caller', async () => {
|
|
133
|
-
currentUserId = 3; // member, not admin
|
|
134
|
-
const res = await request(app)
|
|
135
|
-
.get('/orgs/1/members/4/cascade-preview');
|
|
136
|
-
|
|
137
|
-
expect(res.status).toBe(403);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('preview for member with multiple pending invitations lists all of them', async () => {
|
|
141
|
-
await pool.query(
|
|
142
|
-
`INSERT INTO tm_invitations (org_id, invited_by_user_id, email, role, token_hash, code_encrypted, expires_at)
|
|
143
|
-
VALUES
|
|
144
|
-
(1, 2, 'a@example.com', 'member', $1, 'fake-enc-001', NOW() + INTERVAL '7 days'),
|
|
145
|
-
(1, 2, 'b@example.com', 'viewer', $2, 'fake-enc-002', NOW() + INTERVAL '7 days'),
|
|
146
|
-
(1, 2, 'c@example.com', 'admin', $3, 'fake-enc-003', NOW() + INTERVAL '7 days')`,
|
|
147
|
-
[sha256('tok-001'), sha256('tok-002'), sha256('tok-003')]
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
const res = await request(app)
|
|
151
|
-
.get('/orgs/1/members/2/cascade-preview');
|
|
152
|
-
|
|
153
|
-
expect(res.status).toBeLessThan(500);
|
|
154
|
-
const invitations = res.body.pendingInvitations as unknown[];
|
|
155
|
-
expect(invitations.length).toBe(3);
|
|
156
|
-
});
|
|
157
|
-
});
|