@varshylinc/team-management 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/CHANGELOG.md +159 -0
  3. package/LICENSE +6 -0
  4. package/README.md +97 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +6 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/server/crypto.d.ts +6 -0
  10. package/dist/server/crypto.d.ts.map +1 -0
  11. package/dist/server/crypto.js +42 -0
  12. package/dist/server/crypto.js.map +1 -0
  13. package/dist/server/index.d.ts +34 -0
  14. package/dist/server/index.d.ts.map +1 -0
  15. package/dist/server/index.js +114 -0
  16. package/dist/server/index.js.map +1 -0
  17. package/dist/server/middleware/require-membership.d.ts +10 -0
  18. package/dist/server/middleware/require-membership.d.ts.map +1 -0
  19. package/dist/server/middleware/require-membership.js +33 -0
  20. package/dist/server/middleware/require-membership.js.map +1 -0
  21. package/dist/server/middleware/require-role.d.ts +4 -0
  22. package/dist/server/middleware/require-role.d.ts.map +1 -0
  23. package/dist/server/middleware/require-role.js +16 -0
  24. package/dist/server/middleware/require-role.js.map +1 -0
  25. package/dist/server/middleware/require-super-admin.d.ts +5 -0
  26. package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
  27. package/dist/server/middleware/require-super-admin.js +27 -0
  28. package/dist/server/middleware/require-super-admin.js.map +1 -0
  29. package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
  30. package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
  31. package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
  32. package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
  33. package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
  34. package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
  35. package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
  36. package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
  37. package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
  38. package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
  39. package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
  40. package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
  41. package/dist/server/routes/admin.routes.d.ts +5 -0
  42. package/dist/server/routes/admin.routes.d.ts.map +1 -0
  43. package/dist/server/routes/admin.routes.js +262 -0
  44. package/dist/server/routes/admin.routes.js.map +1 -0
  45. package/dist/server/routes/audit.routes.d.ts +5 -0
  46. package/dist/server/routes/audit.routes.d.ts.map +1 -0
  47. package/dist/server/routes/audit.routes.js +70 -0
  48. package/dist/server/routes/audit.routes.js.map +1 -0
  49. package/dist/server/routes/health.routes.d.ts +8 -0
  50. package/dist/server/routes/health.routes.d.ts.map +1 -0
  51. package/dist/server/routes/health.routes.js +39 -0
  52. package/dist/server/routes/health.routes.js.map +1 -0
  53. package/dist/server/routes/invitations.routes.d.ts +5 -0
  54. package/dist/server/routes/invitations.routes.d.ts.map +1 -0
  55. package/dist/server/routes/invitations.routes.js +232 -0
  56. package/dist/server/routes/invitations.routes.js.map +1 -0
  57. package/dist/server/routes/me.routes.d.ts +5 -0
  58. package/dist/server/routes/me.routes.d.ts.map +1 -0
  59. package/dist/server/routes/me.routes.js +188 -0
  60. package/dist/server/routes/me.routes.js.map +1 -0
  61. package/dist/server/routes/orgs.routes.d.ts +5 -0
  62. package/dist/server/routes/orgs.routes.d.ts.map +1 -0
  63. package/dist/server/routes/orgs.routes.js +371 -0
  64. package/dist/server/routes/orgs.routes.js.map +1 -0
  65. package/dist/server/routes/transfer.routes.d.ts +5 -0
  66. package/dist/server/routes/transfer.routes.d.ts.map +1 -0
  67. package/dist/server/routes/transfer.routes.js +108 -0
  68. package/dist/server/routes/transfer.routes.js.map +1 -0
  69. package/dist/server/services/audit.service.d.ts +20 -0
  70. package/dist/server/services/audit.service.d.ts.map +1 -0
  71. package/dist/server/services/audit.service.js +23 -0
  72. package/dist/server/services/audit.service.js.map +1 -0
  73. package/dist/server/services/email-change.service.d.ts +16 -0
  74. package/dist/server/services/email-change.service.d.ts.map +1 -0
  75. package/dist/server/services/email-change.service.js +107 -0
  76. package/dist/server/services/email-change.service.js.map +1 -0
  77. package/dist/server/services/invitations.service.d.ts +41 -0
  78. package/dist/server/services/invitations.service.d.ts.map +1 -0
  79. package/dist/server/services/invitations.service.js +214 -0
  80. package/dist/server/services/invitations.service.js.map +1 -0
  81. package/dist/server/services/memberships.service.d.ts +27 -0
  82. package/dist/server/services/memberships.service.d.ts.map +1 -0
  83. package/dist/server/services/memberships.service.js +69 -0
  84. package/dist/server/services/memberships.service.js.map +1 -0
  85. package/dist/server/services/organizations.service.d.ts +19 -0
  86. package/dist/server/services/organizations.service.d.ts.map +1 -0
  87. package/dist/server/services/organizations.service.js +61 -0
  88. package/dist/server/services/organizations.service.js.map +1 -0
  89. package/dist/server/services/ownership.service.d.ts +19 -0
  90. package/dist/server/services/ownership.service.d.ts.map +1 -0
  91. package/dist/server/services/ownership.service.js +102 -0
  92. package/dist/server/services/ownership.service.js.map +1 -0
  93. package/dist/server/services/password-reset.service.d.ts +12 -0
  94. package/dist/server/services/password-reset.service.d.ts.map +1 -0
  95. package/dist/server/services/password-reset.service.js +54 -0
  96. package/dist/server/services/password-reset.service.js.map +1 -0
  97. package/dist/server/services/super-admin.service.d.ts +59 -0
  98. package/dist/server/services/super-admin.service.d.ts.map +1 -0
  99. package/dist/server/services/super-admin.service.js +187 -0
  100. package/dist/server/services/super-admin.service.js.map +1 -0
  101. package/dist/server/types.d.ts +186 -0
  102. package/dist/server/types.d.ts.map +1 -0
  103. package/dist/server/types.js +6 -0
  104. package/dist/server/types.js.map +1 -0
  105. package/dist/shared/types.d.ts +23 -0
  106. package/dist/shared/types.d.ts.map +1 -0
  107. package/dist/shared/types.js +6 -0
  108. package/dist/shared/types.js.map +1 -0
  109. package/package.json +56 -0
  110. package/src/client/api.ts +314 -0
  111. package/src/client/components/AuditEventRow.tsx +59 -0
  112. package/src/client/components/CascadePreview.tsx +36 -0
  113. package/src/client/components/DangerZoneCard.tsx +103 -0
  114. package/src/client/components/InvitationCodeDisplay.tsx +48 -0
  115. package/src/client/components/InviteForm.tsx +77 -0
  116. package/src/client/components/MemberRow.tsx +69 -0
  117. package/src/client/components/PendingTransferBanner.tsx +98 -0
  118. package/src/client/components/PlaceholderCard.tsx +26 -0
  119. package/src/client/components/RoleBadge.tsx +26 -0
  120. package/src/client/components/RoleSelect.tsx +35 -0
  121. package/src/client/hooks/.gitkeep +0 -0
  122. package/src/client/hooks/useCurrentMembership.ts +24 -0
  123. package/src/client/hooks/useMembers.ts +24 -0
  124. package/src/client/hooks/usePendingInvitations.ts +24 -0
  125. package/src/client/hooks/usePendingTransfer.ts +27 -0
  126. package/src/client/index.ts +80 -0
  127. package/src/client/pages/AuditLogPage.tsx +164 -0
  128. package/src/client/pages/EmailChangePage.tsx +144 -0
  129. package/src/client/pages/InvitationAcceptPage.tsx +163 -0
  130. package/src/client/pages/InvitationCodePage.tsx +108 -0
  131. package/src/client/pages/MembersPage.tsx +290 -0
  132. package/src/client/pages/OrgSettingsPage.tsx +185 -0
  133. package/src/client/pages/OwnershipTransferPage.tsx +163 -0
  134. package/src/client/pages/PasswordResetPage.tsx +104 -0
  135. package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
  136. package/src/client/pages/PlaceholderPage.tsx +20 -0
  137. package/src/client/pages/SuperAdminDashboard.tsx +401 -0
  138. package/src/client/types.ts +78 -0
  139. package/src/index.ts +24 -0
  140. package/src/server/crypto.ts +47 -0
  141. package/src/server/index.ts +167 -0
  142. package/src/server/middleware/require-membership.ts +48 -0
  143. package/src/server/middleware/require-role.ts +19 -0
  144. package/src/server/middleware/require-super-admin.ts +32 -0
  145. package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
  146. package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
  147. package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
  148. package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
  149. package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
  150. package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
  151. package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
  152. package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
  153. package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
  154. package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
  155. package/src/server/migrations/0011_seed_super_admin.sql +15 -0
  156. package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
  157. package/src/server/routes/admin.routes.ts +208 -0
  158. package/src/server/routes/audit.routes.ts +93 -0
  159. package/src/server/routes/health.routes.ts +46 -0
  160. package/src/server/routes/invitations.routes.ts +252 -0
  161. package/src/server/routes/me.routes.ts +143 -0
  162. package/src/server/routes/orgs.routes.ts +428 -0
  163. package/src/server/routes/transfer.routes.ts +110 -0
  164. package/src/server/services/.gitkeep +0 -0
  165. package/src/server/services/audit.service.ts +49 -0
  166. package/src/server/services/email-change.service.ts +178 -0
  167. package/src/server/services/invitations.service.ts +316 -0
  168. package/src/server/services/memberships.service.ts +129 -0
  169. package/src/server/services/organizations.service.ts +110 -0
  170. package/src/server/services/ownership.service.ts +170 -0
  171. package/src/server/services/password-reset.service.ts +94 -0
  172. package/src/server/services/super-admin.service.ts +321 -0
  173. package/src/server/sql/.gitkeep +0 -0
  174. package/src/server/types.ts +145 -0
  175. package/src/shared/types.ts +24 -0
  176. package/tests/integration/audit-fires.test.ts +288 -0
  177. package/tests/integration/cascade-preview.test.ts +157 -0
  178. package/tests/integration/email-change.test.ts +190 -0
  179. package/tests/integration/feature-flags.test.ts +213 -0
  180. package/tests/integration/invitations-code.test.ts +218 -0
  181. package/tests/integration/invitations-expiry.test.ts +216 -0
  182. package/tests/integration/invitations-resend.test.ts +241 -0
  183. package/tests/integration/invitations-revoke.test.ts +226 -0
  184. package/tests/integration/invitations-switch-org.test.ts +156 -0
  185. package/tests/integration/invitations-token.test.ts +221 -0
  186. package/tests/integration/migrations.test.ts +119 -0
  187. package/tests/integration/only-owner-protections.test.ts +130 -0
  188. package/tests/integration/org-lifecycle.test.ts +169 -0
  189. package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
  190. package/tests/integration/ownership-transfer-expire.test.ts +171 -0
  191. package/tests/integration/ownership-transfer-happy.test.ts +184 -0
  192. package/tests/integration/ownership-transfer-locks.test.ts +146 -0
  193. package/tests/integration/password-reset.test.ts +200 -0
  194. package/tests/integration/super-admin-actions.test.ts +180 -0
  195. package/tests/integration/super-admin-restrictions.test.ts +209 -0
  196. package/tests/setup/global-setup.ts +20 -0
  197. package/tests/unit/adapter-shape.test.ts +330 -0
  198. package/tests/unit/role-permissions.test.ts +236 -0
  199. package/tests/unit/validation.test.ts +304 -0
  200. package/tsconfig.client.json +13 -0
  201. package/tsconfig.json +12 -0
  202. package/tsconfig.tsbuildinfo +1 -0
  203. package/vitest.config.ts +13 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"AA2IA,oFAAoF;AACpF,MAAM,CAAC,MAAM,cAAc,GAA4B,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;AAExG,MAAM,UAAU,WAAW,CAAC,QAAiB,EAAE,QAAiB;IAC9D,OAAO,cAAc,CAAC,QAAQ,CAAC,IAAI,cAAc,CAAC,QAAQ,CAAC,CAAC;AAC9D,CAAC"}
@@ -0,0 +1,23 @@
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
+ /** A team member as returned by the module's public API. */
6
+ export interface TeamMember {
7
+ id: number;
8
+ userId: number;
9
+ organizationId: number;
10
+ role: 'owner' | 'admin' | 'member';
11
+ joinedAt: string;
12
+ }
13
+ /** An invite as returned by the module's public API (stub shape). */
14
+ export interface TeamInvite {
15
+ id: number;
16
+ organizationId: number;
17
+ email: string;
18
+ role: 'admin' | 'member';
19
+ status: 'pending' | 'accepted' | 'expired';
20
+ createdAt: string;
21
+ expiresAt: string;
22
+ }
23
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qEAAqE;AACrE,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAC;IACzB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IAC3C,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB"}
@@ -0,0 +1,6 @@
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
+ export {};
6
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@varshylinc/team-management",
3
+ "version": "0.1.0",
4
+ "description": "Team management shared module for Varshyl products.",
5
+ "private": false,
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./server": {
14
+ "import": "./dist/server/index.js",
15
+ "types": "./dist/server/index.d.ts"
16
+ },
17
+ "./client": {
18
+ "import": "./dist/client/index.js",
19
+ "types": "./dist/client/index.d.ts"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "express": "^4.18.3"
24
+ },
25
+ "devDependencies": {
26
+ "@types/express": "^4.17.21",
27
+ "@types/node": "^20.12.7",
28
+ "@types/pg": "^8.11.5",
29
+ "@types/react": "^18.3.1",
30
+ "@types/react-dom": "^18.3.0",
31
+ "@types/supertest": "^6.0.2",
32
+ "@typescript-eslint/eslint-plugin": "^7.8.0",
33
+ "@typescript-eslint/parser": "^7.8.0",
34
+ "eslint": "^8.57.0",
35
+ "pg": "^8.11.5",
36
+ "react": "^18.3.1",
37
+ "supertest": "^7.0.0",
38
+ "typescript": "^5.4.5",
39
+ "vitest": "^1.5.0"
40
+ },
41
+ "peerDependencies": {
42
+ "express": "^4.18.3",
43
+ "pg": "^8.11.5"
44
+ },
45
+ "type": "module",
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org/"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json && mkdir -p dist/server/migrations && cp src/server/migrations/*.sql dist/server/migrations/",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json",
53
+ "test": "vitest run",
54
+ "lint": "eslint src --ext .ts,.tsx --max-warnings 0"
55
+ }
56
+ }
@@ -0,0 +1,314 @@
1
+ import type {
2
+ PublicOrg,
3
+ PublicMember,
4
+ PendingInvitation,
5
+ CurrentMembership,
6
+ OwnershipTransfer,
7
+ AuditEvent,
8
+ SuperAdminOrgSummary,
9
+ OrgRole,
10
+ ApiError,
11
+ } from './types.js';
12
+
13
+ export let TM_API_BASE = '/api/team';
14
+
15
+ export function setTmApiBase(base: string): void {
16
+ TM_API_BASE = base;
17
+ }
18
+
19
+ async function fetchTm<T>(
20
+ path: string,
21
+ options: RequestInit = {}
22
+ ): Promise<T> {
23
+ const res = await fetch(`${TM_API_BASE}${path}`, {
24
+ ...options,
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ ...(options.headers ?? {}),
28
+ },
29
+ });
30
+
31
+ if (res.status === 204) {
32
+ return undefined as unknown as T;
33
+ }
34
+
35
+ const data = await res.json().catch(() => ({ error: res.statusText }));
36
+
37
+ if (!res.ok) {
38
+ const err = data as ApiError;
39
+ const msg = err.details ? `${err.error}: ${err.details.join(', ')}` : err.error;
40
+ throw new Error(msg ?? `HTTP ${res.status}`);
41
+ }
42
+
43
+ return data as T;
44
+ }
45
+
46
+ // ─── Orgs ────────────────────────────────────────────────────────────────────
47
+
48
+ export async function getOrg(orgId: number): Promise<PublicOrg> {
49
+ return fetchTm<PublicOrg>(`/orgs/${orgId}`);
50
+ }
51
+
52
+ export async function updateOrg(
53
+ orgId: number,
54
+ data: { name?: string; slug?: string }
55
+ ): Promise<PublicOrg> {
56
+ return fetchTm<PublicOrg>(`/orgs/${orgId}`, {
57
+ method: 'PATCH',
58
+ body: JSON.stringify(data),
59
+ });
60
+ }
61
+
62
+ export async function deleteOrg(orgId: number, confirmName: string): Promise<void> {
63
+ return fetchTm<void>(`/orgs/${orgId}`, {
64
+ method: 'DELETE',
65
+ body: JSON.stringify({ confirmName }),
66
+ });
67
+ }
68
+
69
+ export async function listMembers(
70
+ orgId: number,
71
+ opts?: { includeFormer?: boolean }
72
+ ): Promise<PublicMember[]> {
73
+ const qs = opts?.includeFormer ? '?includeFormer=true' : '';
74
+ return fetchTm<PublicMember[]>(`/orgs/${orgId}/members${qs}`);
75
+ }
76
+
77
+ export async function removeMember(
78
+ orgId: number,
79
+ userId: number,
80
+ reason?: string
81
+ ): Promise<void> {
82
+ return fetchTm<void>(`/orgs/${orgId}/members/${userId}`, {
83
+ method: 'DELETE',
84
+ body: JSON.stringify({ reason }),
85
+ });
86
+ }
87
+
88
+ export async function changeMemberRole(
89
+ orgId: number,
90
+ userId: number,
91
+ newRole: OrgRole
92
+ ): Promise<PublicMember> {
93
+ return fetchTm<PublicMember>(`/orgs/${orgId}/members/${userId}/role`, {
94
+ method: 'PATCH',
95
+ body: JSON.stringify({ role: newRole }),
96
+ });
97
+ }
98
+
99
+ // ─── Invitations ─────────────────────────────────────────────────────────────
100
+
101
+ export async function listInvitations(orgId: number): Promise<PendingInvitation[]> {
102
+ return fetchTm<PendingInvitation[]>(`/orgs/${orgId}/invitations`);
103
+ }
104
+
105
+ export async function createInvitation(
106
+ orgId: number,
107
+ data: { email: string; role: OrgRole }
108
+ ): Promise<PendingInvitation> {
109
+ return fetchTm<PendingInvitation>(`/orgs/${orgId}/invitations`, {
110
+ method: 'POST',
111
+ body: JSON.stringify(data),
112
+ });
113
+ }
114
+
115
+ export async function revokeInvitation(orgId: number, invitationId: number): Promise<void> {
116
+ return fetchTm<void>(`/orgs/${orgId}/invitations/${invitationId}`, {
117
+ method: 'DELETE',
118
+ });
119
+ }
120
+
121
+ export async function resendInvitation(orgId: number, invitationId: number): Promise<void> {
122
+ return fetchTm<void>(`/orgs/${orgId}/invitations/${invitationId}/resend`, {
123
+ method: 'POST',
124
+ });
125
+ }
126
+
127
+ export async function getInvitationCode(
128
+ orgId: number,
129
+ invitationId: number
130
+ ): Promise<{ code: string }> {
131
+ return fetchTm<{ code: string }>(`/orgs/${orgId}/invitations/${invitationId}/code`);
132
+ }
133
+
134
+ export async function acceptInvitationByToken(
135
+ token: string
136
+ ): Promise<{ orgId: number; role: OrgRole }> {
137
+ return fetchTm<{ orgId: number; role: OrgRole }>(`/invitations/accept`, {
138
+ method: 'POST',
139
+ body: JSON.stringify({ token }),
140
+ });
141
+ }
142
+
143
+ export async function acceptInvitationByCode(
144
+ email: string,
145
+ code: string
146
+ ): Promise<{ orgId: number; role: OrgRole }> {
147
+ return fetchTm<{ orgId: number; role: OrgRole }>(`/invitations/accept-code`, {
148
+ method: 'POST',
149
+ body: JSON.stringify({ email, code }),
150
+ });
151
+ }
152
+
153
+ // ─── Me / self-service ───────────────────────────────────────────────────────
154
+
155
+ export async function getMyMembership(): Promise<CurrentMembership> {
156
+ return fetchTm<CurrentMembership>(`/me/membership`);
157
+ }
158
+
159
+ export async function requestEmailChange(newEmail: string): Promise<void> {
160
+ return fetchTm<void>(`/me/email-change`, {
161
+ method: 'POST',
162
+ body: JSON.stringify({ newEmail }),
163
+ });
164
+ }
165
+
166
+ export async function verifyEmailChange(token: string): Promise<void> {
167
+ return fetchTm<void>(`/me/email-change/verify`, {
168
+ method: 'POST',
169
+ body: JSON.stringify({ token }),
170
+ });
171
+ }
172
+
173
+ export async function cancelEmailChange(token: string): Promise<void> {
174
+ return fetchTm<void>(`/me/email-change/cancel`, {
175
+ method: 'POST',
176
+ body: JSON.stringify({ token }),
177
+ });
178
+ }
179
+
180
+ export async function requestPasswordReset(email: string): Promise<void> {
181
+ return fetchTm<void>(`/password-reset/request`, {
182
+ method: 'POST',
183
+ body: JSON.stringify({ email }),
184
+ });
185
+ }
186
+
187
+ export async function resetPassword(token: string, newPassword: string): Promise<void> {
188
+ return fetchTm<void>(`/password-reset/confirm`, {
189
+ method: 'POST',
190
+ body: JSON.stringify({ token, newPassword }),
191
+ });
192
+ }
193
+
194
+ // ─── Ownership transfer ───────────────────────────────────────────────────────
195
+
196
+ export async function getPendingTransfer(orgId: number): Promise<OwnershipTransfer | null> {
197
+ return fetchTm<OwnershipTransfer | null>(`/orgs/${orgId}/transfer`);
198
+ }
199
+
200
+ export async function initiateTransfer(
201
+ orgId: number,
202
+ toUserId: number
203
+ ): Promise<OwnershipTransfer> {
204
+ return fetchTm<OwnershipTransfer>(`/orgs/${orgId}/transfer`, {
205
+ method: 'POST',
206
+ body: JSON.stringify({ toUserId }),
207
+ });
208
+ }
209
+
210
+ export async function acceptTransfer(orgId: number): Promise<void> {
211
+ return fetchTm<void>(`/orgs/${orgId}/transfer/accept`, {
212
+ method: 'POST',
213
+ });
214
+ }
215
+
216
+ export async function cancelTransfer(orgId: number): Promise<void> {
217
+ return fetchTm<void>(`/orgs/${orgId}/transfer/cancel`, {
218
+ method: 'POST',
219
+ });
220
+ }
221
+
222
+ // ─── Audit log ───────────────────────────────────────────────────────────────
223
+
224
+ export async function getAuditLog(
225
+ orgId: number,
226
+ opts?: { page?: number; limit?: number; action?: string }
227
+ ): Promise<{ events: AuditEvent[]; total: number; page: number }> {
228
+ const params = new URLSearchParams();
229
+ if (opts?.page !== undefined) params.set('page', String(opts.page));
230
+ if (opts?.limit !== undefined) params.set('limit', String(opts.limit));
231
+ if (opts?.action) params.set('action', opts.action);
232
+ const qs = params.toString() ? `?${params.toString()}` : '';
233
+ return fetchTm<{ events: AuditEvent[]; total: number; page: number }>(
234
+ `/orgs/${orgId}/audit-log${qs}`
235
+ );
236
+ }
237
+
238
+ // ─── Super-admin ─────────────────────────────────────────────────────────────
239
+
240
+ export async function adminListOrgs(): Promise<SuperAdminOrgSummary[]> {
241
+ return fetchTm<SuperAdminOrgSummary[]>(`/admin/orgs`);
242
+ }
243
+
244
+ export async function adminGetOrg(
245
+ orgId: number
246
+ ): Promise<SuperAdminOrgSummary & { members: PublicMember[] }> {
247
+ return fetchTm<SuperAdminOrgSummary & { members: PublicMember[] }>(`/admin/orgs/${orgId}`);
248
+ }
249
+
250
+ export async function adminRestoreOrg(orgId: number): Promise<void> {
251
+ return fetchTm<void>(`/admin/orgs/${orgId}/restore`, { method: 'POST' });
252
+ }
253
+
254
+ export async function adminAppointOwner(
255
+ orgId: number,
256
+ targetUserId: number,
257
+ reason: string
258
+ ): Promise<void> {
259
+ return fetchTm<void>(`/admin/orgs/${orgId}/appoint-owner`, {
260
+ method: 'POST',
261
+ body: JSON.stringify({ targetUserId, reason }),
262
+ });
263
+ }
264
+
265
+ export async function adminHardDeleteOrg(orgId: number, legalBasis: string): Promise<void> {
266
+ return fetchTm<void>(`/admin/orgs/${orgId}/hard-delete`, {
267
+ method: 'DELETE',
268
+ body: JSON.stringify({ legalBasis }),
269
+ });
270
+ }
271
+
272
+ export async function adminAddMember(
273
+ orgId: number,
274
+ userId: number,
275
+ role: OrgRole,
276
+ reason: string
277
+ ): Promise<void> {
278
+ return fetchTm<void>(`/admin/orgs/${orgId}/members`, {
279
+ method: 'POST',
280
+ body: JSON.stringify({ userId, role, reason }),
281
+ });
282
+ }
283
+
284
+ export async function adminRemoveMember(
285
+ orgId: number,
286
+ userId: number,
287
+ reason: string
288
+ ): Promise<void> {
289
+ return fetchTm<void>(`/admin/orgs/${orgId}/members/${userId}`, {
290
+ method: 'DELETE',
291
+ body: JSON.stringify({ reason }),
292
+ });
293
+ }
294
+
295
+ export async function adminLockUser(userId: number, reason: string): Promise<void> {
296
+ return fetchTm<void>(`/admin/users/${userId}/lock`, {
297
+ method: 'POST',
298
+ body: JSON.stringify({ reason }),
299
+ });
300
+ }
301
+
302
+ export async function adminUnlockUser(userId: number, reason: string): Promise<void> {
303
+ return fetchTm<void>(`/admin/users/${userId}/unlock`, {
304
+ method: 'POST',
305
+ body: JSON.stringify({ reason }),
306
+ });
307
+ }
308
+
309
+ export async function adminResetPassword(userId: number, reason: string): Promise<void> {
310
+ return fetchTm<void>(`/admin/users/${userId}/reset-password`, {
311
+ method: 'POST',
312
+ body: JSON.stringify({ reason }),
313
+ });
314
+ }
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import type { AuditEvent } from '../types.js';
3
+
4
+ interface AuditEventRowProps {
5
+ event: AuditEvent;
6
+ }
7
+
8
+ function humanizeAction(action: string): string {
9
+ return action
10
+ .replace(/_/g, ' ')
11
+ .replace(/\b\w/g, (c) => c.toUpperCase());
12
+ }
13
+
14
+ function formatTimestamp(iso: string): string {
15
+ return new Date(iso).toLocaleString(undefined, {
16
+ year: 'numeric',
17
+ month: 'short',
18
+ day: 'numeric',
19
+ hour: '2-digit',
20
+ minute: '2-digit',
21
+ });
22
+ }
23
+
24
+ export function AuditEventRow({ event }: AuditEventRowProps) {
25
+ const isAdminAction = event.actor_type === 'super_admin';
26
+
27
+ return (
28
+ <tr className="border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors">
29
+ <td className="py-3 px-4">
30
+ <div className="flex items-center gap-2">
31
+ <span className={`text-sm font-medium ${isAdminAction ? 'text-purple-700' : 'text-slate-900'}`}>
32
+ {event.actor_display_name}
33
+ </span>
34
+ {isAdminAction && (
35
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
36
+ Support
37
+ </span>
38
+ )}
39
+ </div>
40
+ </td>
41
+ <td className="py-3 px-4">
42
+ <span className="text-sm text-slate-700">{humanizeAction(event.action)}</span>
43
+ </td>
44
+ <td className="py-3 px-4">
45
+ {event.target_type && event.target_id ? (
46
+ <span className="text-xs text-slate-500">
47
+ {event.target_type} #{event.target_id}
48
+ </span>
49
+ ) : (
50
+ <span className="text-slate-300">—</span>
51
+ )}
52
+ </td>
53
+ <td className="py-3 px-4 text-xs text-slate-500 whitespace-nowrap">
54
+ {formatTimestamp(event.created_at)}
55
+ </td>
56
+ </tr>
57
+ );
58
+ }
59
+
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+
3
+ interface CascadeItem {
4
+ label: string;
5
+ count: number;
6
+ description: string;
7
+ }
8
+
9
+ interface CascadePreviewProps {
10
+ items: CascadeItem[];
11
+ }
12
+
13
+ export function CascadePreview({ items }: CascadePreviewProps) {
14
+ if (items.length === 0) return null;
15
+
16
+ return (
17
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
18
+ <p className="text-sm font-semibold text-amber-800 mb-3">
19
+ The following will be affected:
20
+ </p>
21
+ <ul className="space-y-2">
22
+ {items.map((item, i) => (
23
+ <li key={i} className="flex items-start gap-3">
24
+ <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-200 text-xs font-bold text-amber-800">
25
+ {item.count}
26
+ </span>
27
+ <div>
28
+ <span className="text-sm font-medium text-amber-900">{item.label}</span>
29
+ <p className="text-xs text-amber-700">{item.description}</p>
30
+ </div>
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,103 @@
1
+ import React, { useState } from 'react';
2
+
3
+ interface DangerZoneCardProps {
4
+ title: string;
5
+ description: string;
6
+ buttonLabel: string;
7
+ onConfirm: () => Promise<void> | void;
8
+ confirmPrompt?: string;
9
+ }
10
+
11
+ export function DangerZoneCard({
12
+ title,
13
+ description,
14
+ buttonLabel,
15
+ onConfirm,
16
+ confirmPrompt,
17
+ }: DangerZoneCardProps) {
18
+ const [showModal, setShowModal] = useState(false);
19
+ const [confirmText, setConfirmText] = useState('');
20
+ const [submitting, setSubmitting] = useState(false);
21
+ const [error, setError] = useState<string | null>(null);
22
+
23
+ const isConfirmed = !confirmPrompt || confirmText === confirmPrompt;
24
+
25
+ async function handleConfirm() {
26
+ if (!isConfirmed) return;
27
+ setSubmitting(true);
28
+ setError(null);
29
+ try {
30
+ await onConfirm();
31
+ setShowModal(false);
32
+ setConfirmText('');
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : 'Action failed');
35
+ } finally {
36
+ setSubmitting(false);
37
+ }
38
+ }
39
+
40
+ return (
41
+ <>
42
+ <div className="border border-red-200 rounded-lg p-5 bg-red-50">
43
+ <div className="flex flex-col sm:flex-row sm:items-center gap-4">
44
+ <div className="flex-1">
45
+ <h3 className="text-sm font-semibold text-red-800">{title}</h3>
46
+ <p className="mt-1 text-sm text-red-700">{description}</p>
47
+ </div>
48
+ <button
49
+ onClick={() => setShowModal(true)}
50
+ className="shrink-0 rounded-md border border-red-600 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-600 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1 transition-colors"
51
+ >
52
+ {buttonLabel}
53
+ </button>
54
+ </div>
55
+ </div>
56
+
57
+ {showModal && (
58
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
59
+ <div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
60
+ <h2 className="text-lg font-semibold text-slate-900 mb-2">{title}</h2>
61
+ <p className="text-sm text-slate-600 mb-4">{description}</p>
62
+
63
+ {confirmPrompt && (
64
+ <div className="mb-4">
65
+ <p className="text-sm text-slate-700 mb-1">
66
+ Type <strong className="font-mono">{confirmPrompt}</strong> to confirm:
67
+ </p>
68
+ <input
69
+ type="text"
70
+ value={confirmText}
71
+ onChange={(e) => setConfirmText(e.target.value)}
72
+ placeholder={confirmPrompt}
73
+ className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500"
74
+ />
75
+ </div>
76
+ )}
77
+
78
+ {error && (
79
+ <p className="mb-3 text-sm text-red-600">{error}</p>
80
+ )}
81
+
82
+ <div className="flex gap-3 justify-end">
83
+ <button
84
+ onClick={() => { setShowModal(false); setConfirmText(''); setError(null); }}
85
+ disabled={submitting}
86
+ className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50 transition-colors"
87
+ >
88
+ Cancel
89
+ </button>
90
+ <button
91
+ onClick={handleConfirm}
92
+ disabled={!isConfirmed || submitting}
93
+ className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
94
+ >
95
+ {submitting ? 'Processing…' : buttonLabel}
96
+ </button>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ )}
101
+ </>
102
+ );
103
+ }
@@ -0,0 +1,48 @@
1
+ import React, { useState } from 'react';
2
+ import { getInvitationCode } from '../api.js';
3
+
4
+ interface InvitationCodeDisplayProps {
5
+ orgId: number;
6
+ invitationId: number;
7
+ }
8
+
9
+ export function InvitationCodeDisplay({ orgId, invitationId }: InvitationCodeDisplayProps) {
10
+ const [code, setCode] = useState<string | null>(null);
11
+ const [loading, setLoading] = useState(false);
12
+ const [error, setError] = useState<string | null>(null);
13
+
14
+ async function handleShowCode() {
15
+ if (code) return;
16
+ setLoading(true);
17
+ setError(null);
18
+ try {
19
+ const result = await getInvitationCode(orgId, invitationId);
20
+ setCode(result.code);
21
+ } catch (err) {
22
+ setError(err instanceof Error ? err.message : 'Failed to load code');
23
+ } finally {
24
+ setLoading(false);
25
+ }
26
+ }
27
+
28
+ return (
29
+ <div className="inline-flex items-center gap-2">
30
+ {code ? (
31
+ <span className="font-mono text-sm font-semibold tracking-widest text-slate-900 bg-slate-100 border border-slate-300 rounded px-3 py-1 select-all">
32
+ {code}
33
+ </span>
34
+ ) : (
35
+ <button
36
+ onClick={handleShowCode}
37
+ disabled={loading}
38
+ className="text-xs text-blue-600 hover:text-blue-800 underline disabled:opacity-50 transition-colors"
39
+ >
40
+ {loading ? 'Loading…' : 'Show Code'}
41
+ </button>
42
+ )}
43
+ {error && (
44
+ <span className="text-xs text-red-600">{error}</span>
45
+ )}
46
+ </div>
47
+ );
48
+ }