@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,77 @@
1
+ import React, { useState } from 'react';
2
+ import type { OrgRole } from '../types.js';
3
+ import { createInvitation } from '../api.js';
4
+ import { RoleSelect } from './RoleSelect.js';
5
+
6
+ interface InviteFormProps {
7
+ orgId: number;
8
+ onSuccess: () => void;
9
+ }
10
+
11
+ export function InviteForm({ orgId, onSuccess }: InviteFormProps) {
12
+ const [email, setEmail] = useState('');
13
+ const [role, setRole] = useState<OrgRole>('member');
14
+ const [submitting, setSubmitting] = useState(false);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [success, setSuccess] = useState(false);
17
+
18
+ async function handleSubmit(e: React.FormEvent) {
19
+ e.preventDefault();
20
+ if (!email.trim()) return;
21
+
22
+ setSubmitting(true);
23
+ setError(null);
24
+ setSuccess(false);
25
+
26
+ try {
27
+ await createInvitation(orgId, { email: email.trim(), role });
28
+ setEmail('');
29
+ setRole('member');
30
+ setSuccess(true);
31
+ onSuccess();
32
+ setTimeout(() => setSuccess(false), 3000);
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : 'Failed to send invitation');
35
+ } finally {
36
+ setSubmitting(false);
37
+ }
38
+ }
39
+
40
+ return (
41
+ <form onSubmit={handleSubmit} className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm">
42
+ <h3 className="text-sm font-semibold text-slate-800 mb-3">Invite a team member</h3>
43
+ <div className="flex flex-col sm:flex-row gap-2">
44
+ <input
45
+ type="email"
46
+ placeholder="colleague@company.com"
47
+ value={email}
48
+ onChange={(e) => setEmail(e.target.value)}
49
+ required
50
+ disabled={submitting}
51
+ className="flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-slate-50"
52
+ />
53
+ <div className="w-full sm:w-36">
54
+ <RoleSelect
55
+ value={role}
56
+ onChange={setRole}
57
+ disabled={submitting}
58
+ disabledRoles={['owner']}
59
+ />
60
+ </div>
61
+ <button
62
+ type="submit"
63
+ disabled={submitting || !email.trim()}
64
+ className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
65
+ >
66
+ {submitting ? 'Sending…' : 'Send Invite'}
67
+ </button>
68
+ </div>
69
+ {error && (
70
+ <p className="mt-2 text-sm text-red-600">{error}</p>
71
+ )}
72
+ {success && (
73
+ <p className="mt-2 text-sm text-green-600">Invitation sent successfully!</p>
74
+ )}
75
+ </form>
76
+ );
77
+ }
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import type { PublicMember, OrgRole } from '../types.js';
3
+ import { RoleBadge } from './RoleBadge.js';
4
+ import { RoleSelect } from './RoleSelect.js';
5
+
6
+ interface MemberRowProps {
7
+ member: PublicMember;
8
+ currentUserRole: OrgRole;
9
+ onRemove: (userId: number) => void;
10
+ onRoleChange: (userId: number, newRole: OrgRole) => void;
11
+ }
12
+
13
+ const canManage = (currentRole: OrgRole): boolean =>
14
+ currentRole === 'owner' || currentRole === 'admin';
15
+
16
+
17
+ function formatDate(iso: string): string {
18
+ return new Date(iso).toLocaleDateString(undefined, {
19
+ year: 'numeric',
20
+ month: 'short',
21
+ day: 'numeric',
22
+ });
23
+ }
24
+
25
+ export function MemberRow({ member, currentUserRole, onRemove, onRoleChange }: MemberRowProps) {
26
+ const canEdit = canManage(currentUserRole) && member.role !== 'owner';
27
+ const disabledRoles: OrgRole[] =
28
+ currentUserRole === 'admin' ? ['owner'] : [];
29
+
30
+ return (
31
+ <tr className="border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors">
32
+ <td className="py-3 px-4">
33
+ <div className="flex flex-col">
34
+ <span className="text-sm font-medium text-slate-900">
35
+ {member.name ?? member.email}
36
+ </span>
37
+ {member.name && (
38
+ <span className="text-xs text-slate-500">{member.email}</span>
39
+ )}
40
+ </div>
41
+ </td>
42
+ <td className="py-3 px-4">
43
+ {canEdit ? (
44
+ <RoleSelect
45
+ value={member.role}
46
+ onChange={(r) => onRoleChange(member.user_id, r)}
47
+ disabledRoles={disabledRoles}
48
+ />
49
+ ) : (
50
+ <RoleBadge role={member.role} />
51
+ )}
52
+ </td>
53
+ <td className="py-3 px-4 text-sm text-slate-500 whitespace-nowrap">
54
+ {formatDate(member.joined_at)}
55
+ </td>
56
+ <td className="py-3 px-4 text-right">
57
+ {canEdit && (
58
+ <button
59
+ onClick={() => onRemove(member.user_id)}
60
+ className="text-sm text-red-600 hover:text-red-800 font-medium transition-colors"
61
+ >
62
+ Remove
63
+ </button>
64
+ )}
65
+ </td>
66
+ </tr>
67
+ );
68
+ }
69
+
@@ -0,0 +1,98 @@
1
+ import React, { useState } from 'react';
2
+ import type { OwnershipTransfer } from '../types.js';
3
+ import { acceptTransfer, cancelTransfer } from '../api.js';
4
+
5
+ interface PendingTransferBannerProps {
6
+ transfer: OwnershipTransfer;
7
+ currentUserId: number;
8
+ orgId: number;
9
+ onAction: () => void;
10
+ }
11
+
12
+ function formatDate(iso: string): string {
13
+ return new Date(iso).toLocaleString(undefined, {
14
+ month: 'short',
15
+ day: 'numeric',
16
+ hour: '2-digit',
17
+ minute: '2-digit',
18
+ });
19
+ }
20
+
21
+ export function PendingTransferBanner({
22
+ transfer,
23
+ currentUserId,
24
+ orgId,
25
+ onAction,
26
+ }: PendingTransferBannerProps) {
27
+ const [submitting, setSubmitting] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+
30
+ const isTarget = currentUserId === transfer.to_user_id;
31
+
32
+ async function handleAccept() {
33
+ setSubmitting(true);
34
+ setError(null);
35
+ try {
36
+ await acceptTransfer(orgId);
37
+ onAction();
38
+ } catch (err) {
39
+ setError(err instanceof Error ? err.message : 'Action failed');
40
+ } finally {
41
+ setSubmitting(false);
42
+ }
43
+ }
44
+
45
+ async function handleDecline() {
46
+ setSubmitting(true);
47
+ setError(null);
48
+ try {
49
+ await cancelTransfer(orgId);
50
+ onAction();
51
+ } catch (err) {
52
+ setError(err instanceof Error ? err.message : 'Action failed');
53
+ } finally {
54
+ setSubmitting(false);
55
+ }
56
+ }
57
+
58
+ return (
59
+ <div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 shadow-sm">
60
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3">
61
+ <div className="flex-1">
62
+ <p className="text-sm font-semibold text-amber-900">
63
+ Pending Ownership Transfer
64
+ </p>
65
+ <p className="text-sm text-amber-800 mt-0.5">
66
+ {isTarget
67
+ ? `You have been invited to become the new owner of this organization.`
68
+ : `An ownership transfer has been initiated to user #${transfer.to_user_id}.`}
69
+ </p>
70
+ <p className="text-xs text-amber-700 mt-1">
71
+ Expires {formatDate(transfer.expires_at)}
72
+ </p>
73
+ </div>
74
+ {isTarget && (
75
+ <div className="flex gap-2 shrink-0">
76
+ <button
77
+ onClick={handleAccept}
78
+ disabled={submitting}
79
+ className="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-50 transition-colors"
80
+ >
81
+ Accept
82
+ </button>
83
+ <button
84
+ onClick={handleDecline}
85
+ disabled={submitting}
86
+ className="rounded-md border border-amber-400 bg-white px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100 focus:outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50 transition-colors"
87
+ >
88
+ Decline
89
+ </button>
90
+ </div>
91
+ )}
92
+ </div>
93
+ {error && (
94
+ <p className="mt-2 text-sm text-red-600">{error}</p>
95
+ )}
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * PlaceholderCard — rendered by host products while the full
5
+ * Team Management feature is in development.
6
+ */
7
+ export function PlaceholderCard(): React.ReactElement {
8
+ return (
9
+ <div style={{
10
+ border: '1px solid #e2e8f0',
11
+ borderRadius: '8px',
12
+ padding: '24px',
13
+ maxWidth: '400px',
14
+ fontFamily: 'system-ui, sans-serif',
15
+ }}>
16
+ <div style={{ fontSize: '24px', marginBottom: '8px' }}>👥</div>
17
+ <h3 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#1a2230' }}>
18
+ Team Management
19
+ </h3>
20
+ <p style={{ margin: 0, fontSize: '14px', color: '#64748b' }}>
21
+ Coming soon. Invite teammates, manage roles, and control access
22
+ to your Varshyl products.
23
+ </p>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import type { OrgRole } from '../types.js';
3
+
4
+ const roleStyles: Record<OrgRole, string> = {
5
+ owner: 'bg-purple-100 text-purple-800 border border-purple-300',
6
+ admin: 'bg-blue-100 text-blue-800 border border-blue-300',
7
+ member: 'bg-green-100 text-green-800 border border-green-300',
8
+ viewer: 'bg-slate-100 text-slate-700 border border-slate-300',
9
+ };
10
+
11
+ const roleLabels: Record<OrgRole, string> = {
12
+ owner: 'Owner',
13
+ admin: 'Admin',
14
+ member: 'Member',
15
+ viewer: 'Viewer',
16
+ };
17
+
18
+ export function RoleBadge({ role }: { role: OrgRole }) {
19
+ return (
20
+ <span
21
+ className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${roleStyles[role]}`}
22
+ >
23
+ {roleLabels[role]}
24
+ </span>
25
+ );
26
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import type { OrgRole } from '../types.js';
3
+
4
+ const ALL_ROLES: OrgRole[] = ['owner', 'admin', 'member', 'viewer'];
5
+
6
+ const roleLabels: Record<OrgRole, string> = {
7
+ owner: 'Owner',
8
+ admin: 'Admin',
9
+ member: 'Member',
10
+ viewer: 'Viewer',
11
+ };
12
+
13
+ interface RoleSelectProps {
14
+ value: OrgRole;
15
+ onChange: (r: OrgRole) => void;
16
+ disabled?: boolean;
17
+ disabledRoles?: OrgRole[];
18
+ }
19
+
20
+ export function RoleSelect({ value, onChange, disabled, disabledRoles = [] }: RoleSelectProps) {
21
+ return (
22
+ <select
23
+ value={value}
24
+ onChange={(e) => onChange(e.target.value as OrgRole)}
25
+ disabled={disabled}
26
+ className="block w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
27
+ >
28
+ {ALL_ROLES.map((r) => (
29
+ <option key={r} value={r} disabled={disabledRoles.includes(r)}>
30
+ {roleLabels[r]}
31
+ </option>
32
+ ))}
33
+ </select>
34
+ );
35
+ }
File without changes
@@ -0,0 +1,24 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { getMyMembership } from '../api.js';
3
+ import type { CurrentMembership } from '../types.js';
4
+
5
+ export function useCurrentMembership() {
6
+ const [membership, setMembership] = useState<CurrentMembership | null>(null);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ const load = useCallback(() => {
11
+ setLoading(true);
12
+ setError(null);
13
+ getMyMembership()
14
+ .then(setMembership)
15
+ .catch((e: Error) => setError(e.message))
16
+ .finally(() => setLoading(false));
17
+ }, []);
18
+
19
+ useEffect(() => {
20
+ load();
21
+ }, [load]);
22
+
23
+ return { membership, loading, error, refresh: load };
24
+ }
@@ -0,0 +1,24 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { listMembers } from '../api.js';
3
+ import type { PublicMember } from '../types.js';
4
+
5
+ export function useMembers(orgId: number, opts?: { includeFormer?: boolean }) {
6
+ const [members, setMembers] = useState<PublicMember[]>([]);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ const load = useCallback(() => {
11
+ setLoading(true);
12
+ setError(null);
13
+ listMembers(orgId, opts)
14
+ .then(setMembers)
15
+ .catch((e: Error) => setError(e.message))
16
+ .finally(() => setLoading(false));
17
+ }, [orgId, opts?.includeFormer]);
18
+
19
+ useEffect(() => {
20
+ load();
21
+ }, [load]);
22
+
23
+ return { members, loading, error, refresh: load };
24
+ }
@@ -0,0 +1,24 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { listInvitations } from '../api.js';
3
+ import type { PendingInvitation } from '../types.js';
4
+
5
+ export function usePendingInvitations(orgId: number) {
6
+ const [invitations, setInvitations] = useState<PendingInvitation[]>([]);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ const load = useCallback(() => {
11
+ setLoading(true);
12
+ setError(null);
13
+ listInvitations(orgId)
14
+ .then(setInvitations)
15
+ .catch((e: Error) => setError(e.message))
16
+ .finally(() => setLoading(false));
17
+ }, [orgId]);
18
+
19
+ useEffect(() => {
20
+ load();
21
+ }, [load]);
22
+
23
+ return { invitations, loading, error, refresh: load };
24
+ }
@@ -0,0 +1,27 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { getPendingTransfer } from '../api.js';
3
+ import type { OwnershipTransfer } from '../types.js';
4
+
5
+ export function usePendingTransfer(orgId: number) {
6
+ const [transfer, setTransfer] = useState<OwnershipTransfer | null>(null);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ const load = useCallback(() => {
11
+ setLoading(true);
12
+ setError(null);
13
+ getPendingTransfer(orgId)
14
+ .then(setTransfer)
15
+ .catch((e: Error) => setError(e.message))
16
+ .finally(() => setLoading(false));
17
+ }, [orgId]);
18
+
19
+ useEffect(() => {
20
+ load();
21
+ // Poll every 30 seconds for transfer status changes
22
+ const interval = setInterval(load, 30_000);
23
+ return () => clearInterval(interval);
24
+ }, [load]);
25
+
26
+ return { transfer, loading, error, refresh: load };
27
+ }
@@ -0,0 +1,80 @@
1
+ export { MembersPage } from './pages/MembersPage.js';
2
+ export { OrgSettingsPage } from './pages/OrgSettingsPage.js';
3
+ export { InvitationAcceptPage } from './pages/InvitationAcceptPage.js';
4
+ export { InvitationCodePage } from './pages/InvitationCodePage.js';
5
+ export { AuditLogPage } from './pages/AuditLogPage.js';
6
+ export { OwnershipTransferPage } from './pages/OwnershipTransferPage.js';
7
+ export { EmailChangePage } from './pages/EmailChangePage.js';
8
+ export { PasswordResetRequestPage } from './pages/PasswordResetRequestPage.js';
9
+ export { PasswordResetPage } from './pages/PasswordResetPage.js';
10
+ export { SuperAdminDashboard } from './pages/SuperAdminDashboard.js';
11
+
12
+ // Components
13
+ export { RoleBadge } from './components/RoleBadge.js';
14
+ export { RoleSelect } from './components/RoleSelect.js';
15
+ export { MemberRow } from './components/MemberRow.js';
16
+ export { InviteForm } from './components/InviteForm.js';
17
+ export { InvitationCodeDisplay } from './components/InvitationCodeDisplay.js';
18
+ export { AuditEventRow } from './components/AuditEventRow.js';
19
+ export { DangerZoneCard } from './components/DangerZoneCard.js';
20
+ export { CascadePreview } from './components/CascadePreview.js';
21
+ export { PendingTransferBanner } from './components/PendingTransferBanner.js';
22
+
23
+ // Hooks
24
+ export { useCurrentMembership } from './hooks/useCurrentMembership.js';
25
+ export { useMembers } from './hooks/useMembers.js';
26
+ export { usePendingInvitations } from './hooks/usePendingInvitations.js';
27
+ export { usePendingTransfer } from './hooks/usePendingTransfer.js';
28
+
29
+ // API
30
+ export {
31
+ setTmApiBase,
32
+ getOrg,
33
+ updateOrg,
34
+ deleteOrg,
35
+ listMembers,
36
+ removeMember,
37
+ changeMemberRole,
38
+ listInvitations,
39
+ createInvitation,
40
+ revokeInvitation,
41
+ resendInvitation,
42
+ getInvitationCode,
43
+ acceptInvitationByToken,
44
+ acceptInvitationByCode,
45
+ getMyMembership,
46
+ requestEmailChange,
47
+ verifyEmailChange,
48
+ cancelEmailChange,
49
+ requestPasswordReset,
50
+ resetPassword,
51
+ getPendingTransfer,
52
+ initiateTransfer,
53
+ acceptTransfer,
54
+ cancelTransfer,
55
+ getAuditLog,
56
+ adminListOrgs,
57
+ adminGetOrg,
58
+ adminRestoreOrg,
59
+ adminAppointOwner,
60
+ adminHardDeleteOrg,
61
+ adminAddMember,
62
+ adminRemoveMember,
63
+ adminLockUser,
64
+ adminUnlockUser,
65
+ adminResetPassword,
66
+ } from './api.js';
67
+
68
+ // Types
69
+ export type {
70
+ OrgRole,
71
+ TransferStatus,
72
+ PublicOrg,
73
+ PublicMember,
74
+ PendingInvitation,
75
+ AuditEvent,
76
+ OwnershipTransfer,
77
+ CurrentMembership,
78
+ SuperAdminOrgSummary,
79
+ ApiError,
80
+ } from './types.js';
@@ -0,0 +1,164 @@
1
+ import React, { useState } from 'react';
2
+ import { useCurrentMembership } from '../hooks/useCurrentMembership.js';
3
+ import { AuditEventRow } from '../components/AuditEventRow.js';
4
+ import { getAuditLog } from '../api.js';
5
+ import type { AuditEvent } from '../types.js';
6
+
7
+ interface AuditLogPageProps {
8
+ orgId: number;
9
+ }
10
+
11
+ const PAGE_SIZE = 25;
12
+
13
+ export function AuditLogPage({ orgId }: AuditLogPageProps) {
14
+ const { membership } = useCurrentMembership();
15
+ const [events, setEvents] = useState<AuditEvent[]>([]);
16
+ const [total, setTotal] = useState(0);
17
+ const [page, setPage] = useState(1);
18
+ const [loading, setLoading] = useState(false);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [actionFilter, setActionFilter] = useState('');
21
+ const [initialized, setInitialized] = useState(false);
22
+
23
+ const role = membership?.role;
24
+ const canView = role === 'owner' || role === 'admin';
25
+
26
+ async function load(p: number, action: string) {
27
+ setLoading(true);
28
+ setError(null);
29
+ try {
30
+ const result = await getAuditLog(orgId, { page: p, limit: PAGE_SIZE, action: action || undefined });
31
+ setEvents(result.events);
32
+ setTotal(result.total);
33
+ setPage(result.page);
34
+ } catch (err) {
35
+ setError(err instanceof Error ? err.message : 'Failed to load audit log');
36
+ } finally {
37
+ setLoading(false);
38
+ setInitialized(true);
39
+ }
40
+ }
41
+
42
+ // Load on first render after membership check
43
+ React.useEffect(() => {
44
+ if (canView && !initialized) {
45
+ load(1, actionFilter);
46
+ }
47
+ }, [canView]);
48
+
49
+ async function handleFilter(e: React.FormEvent) {
50
+ e.preventDefault();
51
+ await load(1, actionFilter);
52
+ }
53
+
54
+ const totalPages = Math.ceil(total / PAGE_SIZE);
55
+
56
+ if (!canView) {
57
+ return (
58
+ <div className="max-w-4xl mx-auto px-4 py-8">
59
+ <div className="rounded-md bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
60
+ You do not have permission to view the audit log.
61
+ </div>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <div className="max-w-5xl mx-auto px-4 py-8">
68
+ <div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-6">
69
+ <h1 className="text-2xl font-bold text-slate-900 flex-1">Audit Log</h1>
70
+ <form onSubmit={handleFilter} className="flex gap-2">
71
+ <input
72
+ type="text"
73
+ value={actionFilter}
74
+ onChange={(e) => setActionFilter(e.target.value)}
75
+ placeholder="Filter by action…"
76
+ className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
77
+ />
78
+ <button
79
+ type="submit"
80
+ className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
81
+ >
82
+ Filter
83
+ </button>
84
+ {actionFilter && (
85
+ <button
86
+ type="button"
87
+ onClick={() => { setActionFilter(''); load(1, ''); }}
88
+ className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
89
+ >
90
+ Clear
91
+ </button>
92
+ )}
93
+ </form>
94
+ </div>
95
+
96
+ {error && (
97
+ <div className="rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 mb-4">
98
+ {error}
99
+ </div>
100
+ )}
101
+
102
+ {loading ? (
103
+ <div className="flex items-center justify-center py-16">
104
+ <div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
105
+ </div>
106
+ ) : (
107
+ <>
108
+ <div className="overflow-hidden rounded-lg border border-slate-200 shadow-sm">
109
+ <table className="w-full text-left">
110
+ <thead className="bg-slate-50 border-b border-slate-200">
111
+ <tr>
112
+ <th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">Actor</th>
113
+ <th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
114
+ <th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">Target</th>
115
+ <th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">When</th>
116
+ </tr>
117
+ </thead>
118
+ <tbody className="bg-white divide-y divide-slate-100">
119
+ {events.map((event) => (
120
+ <AuditEventRow key={event.id} event={event} />
121
+ ))}
122
+ {events.length === 0 && initialized && (
123
+ <tr>
124
+ <td colSpan={4} className="py-10 text-center text-sm text-slate-500">
125
+ No audit events found.
126
+ </td>
127
+ </tr>
128
+ )}
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+
133
+ {/* Pagination */}
134
+ {totalPages > 1 && (
135
+ <div className="mt-4 flex items-center justify-between">
136
+ <p className="text-sm text-slate-600">
137
+ Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total} events
138
+ </p>
139
+ <div className="flex gap-2">
140
+ <button
141
+ onClick={() => load(page - 1, actionFilter)}
142
+ disabled={page <= 1 || loading}
143
+ className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
144
+ >
145
+ Previous
146
+ </button>
147
+ <span className="flex items-center px-2 text-sm text-slate-600">
148
+ {page} / {totalPages}
149
+ </span>
150
+ <button
151
+ onClick={() => load(page + 1, actionFilter)}
152
+ disabled={page >= totalPages || loading}
153
+ className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
154
+ >
155
+ Next
156
+ </button>
157
+ </div>
158
+ </div>
159
+ )}
160
+ </>
161
+ )}
162
+ </div>
163
+ );
164
+ }