@varshylinc/team-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +18 -0
- package/CHANGELOG.md +159 -0
- package/LICENSE +6 -0
- package/README.md +97 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/server/crypto.d.ts +6 -0
- package/dist/server/crypto.d.ts.map +1 -0
- package/dist/server/crypto.js +42 -0
- package/dist/server/crypto.js.map +1 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +114 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware/require-membership.d.ts +10 -0
- package/dist/server/middleware/require-membership.d.ts.map +1 -0
- package/dist/server/middleware/require-membership.js +33 -0
- package/dist/server/middleware/require-membership.js.map +1 -0
- package/dist/server/middleware/require-role.d.ts +4 -0
- package/dist/server/middleware/require-role.d.ts.map +1 -0
- package/dist/server/middleware/require-role.js +16 -0
- package/dist/server/middleware/require-role.js.map +1 -0
- package/dist/server/middleware/require-super-admin.d.ts +5 -0
- package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
- package/dist/server/middleware/require-super-admin.js +27 -0
- package/dist/server/middleware/require-super-admin.js.map +1 -0
- package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
- package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/dist/server/routes/admin.routes.d.ts +5 -0
- package/dist/server/routes/admin.routes.d.ts.map +1 -0
- package/dist/server/routes/admin.routes.js +262 -0
- package/dist/server/routes/admin.routes.js.map +1 -0
- package/dist/server/routes/audit.routes.d.ts +5 -0
- package/dist/server/routes/audit.routes.d.ts.map +1 -0
- package/dist/server/routes/audit.routes.js +70 -0
- package/dist/server/routes/audit.routes.js.map +1 -0
- package/dist/server/routes/health.routes.d.ts +8 -0
- package/dist/server/routes/health.routes.d.ts.map +1 -0
- package/dist/server/routes/health.routes.js +39 -0
- package/dist/server/routes/health.routes.js.map +1 -0
- package/dist/server/routes/invitations.routes.d.ts +5 -0
- package/dist/server/routes/invitations.routes.d.ts.map +1 -0
- package/dist/server/routes/invitations.routes.js +232 -0
- package/dist/server/routes/invitations.routes.js.map +1 -0
- package/dist/server/routes/me.routes.d.ts +5 -0
- package/dist/server/routes/me.routes.d.ts.map +1 -0
- package/dist/server/routes/me.routes.js +188 -0
- package/dist/server/routes/me.routes.js.map +1 -0
- package/dist/server/routes/orgs.routes.d.ts +5 -0
- package/dist/server/routes/orgs.routes.d.ts.map +1 -0
- package/dist/server/routes/orgs.routes.js +371 -0
- package/dist/server/routes/orgs.routes.js.map +1 -0
- package/dist/server/routes/transfer.routes.d.ts +5 -0
- package/dist/server/routes/transfer.routes.d.ts.map +1 -0
- package/dist/server/routes/transfer.routes.js +108 -0
- package/dist/server/routes/transfer.routes.js.map +1 -0
- package/dist/server/services/audit.service.d.ts +20 -0
- package/dist/server/services/audit.service.d.ts.map +1 -0
- package/dist/server/services/audit.service.js +23 -0
- package/dist/server/services/audit.service.js.map +1 -0
- package/dist/server/services/email-change.service.d.ts +16 -0
- package/dist/server/services/email-change.service.d.ts.map +1 -0
- package/dist/server/services/email-change.service.js +107 -0
- package/dist/server/services/email-change.service.js.map +1 -0
- package/dist/server/services/invitations.service.d.ts +41 -0
- package/dist/server/services/invitations.service.d.ts.map +1 -0
- package/dist/server/services/invitations.service.js +214 -0
- package/dist/server/services/invitations.service.js.map +1 -0
- package/dist/server/services/memberships.service.d.ts +27 -0
- package/dist/server/services/memberships.service.d.ts.map +1 -0
- package/dist/server/services/memberships.service.js +69 -0
- package/dist/server/services/memberships.service.js.map +1 -0
- package/dist/server/services/organizations.service.d.ts +19 -0
- package/dist/server/services/organizations.service.d.ts.map +1 -0
- package/dist/server/services/organizations.service.js +61 -0
- package/dist/server/services/organizations.service.js.map +1 -0
- package/dist/server/services/ownership.service.d.ts +19 -0
- package/dist/server/services/ownership.service.d.ts.map +1 -0
- package/dist/server/services/ownership.service.js +102 -0
- package/dist/server/services/ownership.service.js.map +1 -0
- package/dist/server/services/password-reset.service.d.ts +12 -0
- package/dist/server/services/password-reset.service.d.ts.map +1 -0
- package/dist/server/services/password-reset.service.js +54 -0
- package/dist/server/services/password-reset.service.js.map +1 -0
- package/dist/server/services/super-admin.service.d.ts +59 -0
- package/dist/server/services/super-admin.service.d.ts.map +1 -0
- package/dist/server/services/super-admin.service.js +187 -0
- package/dist/server/services/super-admin.service.js.map +1 -0
- package/dist/server/types.d.ts +186 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +6 -0
- package/dist/server/types.js.map +1 -0
- package/dist/shared/types.d.ts +23 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +6 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +56 -0
- package/src/client/api.ts +314 -0
- package/src/client/components/AuditEventRow.tsx +59 -0
- package/src/client/components/CascadePreview.tsx +36 -0
- package/src/client/components/DangerZoneCard.tsx +103 -0
- package/src/client/components/InvitationCodeDisplay.tsx +48 -0
- package/src/client/components/InviteForm.tsx +77 -0
- package/src/client/components/MemberRow.tsx +69 -0
- package/src/client/components/PendingTransferBanner.tsx +98 -0
- package/src/client/components/PlaceholderCard.tsx +26 -0
- package/src/client/components/RoleBadge.tsx +26 -0
- package/src/client/components/RoleSelect.tsx +35 -0
- package/src/client/hooks/.gitkeep +0 -0
- package/src/client/hooks/useCurrentMembership.ts +24 -0
- package/src/client/hooks/useMembers.ts +24 -0
- package/src/client/hooks/usePendingInvitations.ts +24 -0
- package/src/client/hooks/usePendingTransfer.ts +27 -0
- package/src/client/index.ts +80 -0
- package/src/client/pages/AuditLogPage.tsx +164 -0
- package/src/client/pages/EmailChangePage.tsx +144 -0
- package/src/client/pages/InvitationAcceptPage.tsx +163 -0
- package/src/client/pages/InvitationCodePage.tsx +108 -0
- package/src/client/pages/MembersPage.tsx +290 -0
- package/src/client/pages/OrgSettingsPage.tsx +185 -0
- package/src/client/pages/OwnershipTransferPage.tsx +163 -0
- package/src/client/pages/PasswordResetPage.tsx +104 -0
- package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
- package/src/client/pages/PlaceholderPage.tsx +20 -0
- package/src/client/pages/SuperAdminDashboard.tsx +401 -0
- package/src/client/types.ts +78 -0
- package/src/index.ts +24 -0
- package/src/server/crypto.ts +47 -0
- package/src/server/index.ts +167 -0
- package/src/server/middleware/require-membership.ts +48 -0
- package/src/server/middleware/require-role.ts +19 -0
- package/src/server/middleware/require-super-admin.ts +32 -0
- package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/src/server/migrations/0011_seed_super_admin.sql +15 -0
- package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/src/server/routes/admin.routes.ts +208 -0
- package/src/server/routes/audit.routes.ts +93 -0
- package/src/server/routes/health.routes.ts +46 -0
- package/src/server/routes/invitations.routes.ts +252 -0
- package/src/server/routes/me.routes.ts +143 -0
- package/src/server/routes/orgs.routes.ts +428 -0
- package/src/server/routes/transfer.routes.ts +110 -0
- package/src/server/services/.gitkeep +0 -0
- package/src/server/services/audit.service.ts +49 -0
- package/src/server/services/email-change.service.ts +178 -0
- package/src/server/services/invitations.service.ts +316 -0
- package/src/server/services/memberships.service.ts +129 -0
- package/src/server/services/organizations.service.ts +110 -0
- package/src/server/services/ownership.service.ts +170 -0
- package/src/server/services/password-reset.service.ts +94 -0
- package/src/server/services/super-admin.service.ts +321 -0
- package/src/server/sql/.gitkeep +0 -0
- package/src/server/types.ts +145 -0
- package/src/shared/types.ts +24 -0
- package/tests/integration/audit-fires.test.ts +288 -0
- package/tests/integration/cascade-preview.test.ts +157 -0
- package/tests/integration/email-change.test.ts +190 -0
- package/tests/integration/feature-flags.test.ts +213 -0
- package/tests/integration/invitations-code.test.ts +218 -0
- package/tests/integration/invitations-expiry.test.ts +216 -0
- package/tests/integration/invitations-resend.test.ts +241 -0
- package/tests/integration/invitations-revoke.test.ts +226 -0
- package/tests/integration/invitations-switch-org.test.ts +156 -0
- package/tests/integration/invitations-token.test.ts +221 -0
- package/tests/integration/migrations.test.ts +119 -0
- package/tests/integration/only-owner-protections.test.ts +130 -0
- package/tests/integration/org-lifecycle.test.ts +169 -0
- package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
- package/tests/integration/ownership-transfer-expire.test.ts +171 -0
- package/tests/integration/ownership-transfer-happy.test.ts +184 -0
- package/tests/integration/ownership-transfer-locks.test.ts +146 -0
- package/tests/integration/password-reset.test.ts +200 -0
- package/tests/integration/super-admin-actions.test.ts +180 -0
- package/tests/integration/super-admin-restrictions.test.ts +209 -0
- package/tests/setup/global-setup.ts +20 -0
- package/tests/unit/adapter-shape.test.ts +330 -0
- package/tests/unit/role-permissions.test.ts +236 -0
- package/tests/unit/validation.test.ts +304 -0
- package/tsconfig.client.json +13 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|