diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c1b87343c..022f57783 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -708,6 +708,15 @@ membership and derive the bounded effective role before it lands in protected hosted routes. Direct opens must fail closed on missing membership, blank-owner orgs, or owner/admin role escalation attempts instead of diverging from the newer portal exchange path by repairing org metadata on arrival. +That same shared `internal/api/` organization boundary also now assumes +self-hosted org membership is consent-backed rather than manager-written for a +target user ID. Lifecycle-adjacent setup, install, or hosted-entry surfaces +may call `/api/orgs/{id}/members`, but a new user must land in a pending +invitation record and become a real member only after the invited account +accepts through the canonical invitation routes. Owner transfer remains an +existing-member operation on that same boundary; lifecycle-adjacent flows may +not treat an unaccepted invitation or arbitrary `userId` string as a +member-shaped owner target. That same shared `internal/api/` dependency also assumes telemetry transparency remains explicitly system-settings-owned. When lifecycle-adjacent setup or router work touches shared `internal/api/` files, telemetry preview diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 47c2c34ea..63785f188 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -219,6 +219,12 @@ the canonical monitored-system blocked payload. canonical invitation, membership-management, or explicit owner-transfer flows may create tenant membership or change the stored owner/admin role. Shared auth routes and downstream settings consumers must treat handoff role claims as bounded by the server-owned membership record, never as authority to elevate tenant privileges. + That same org-management transport now owns explicit acceptance for new self-hosted membership as well. + `internal/api/org_handlers.go`, `frontend-modern/src/api/orgs.ts`, and `internal/api/contract_test.go` must + keep new-user adds on the canonical pending-invitation payload (`kind:"invitation"`) plus current-user + accept/decline routes, rather than binding an arbitrary username directly into `org.Members`. Immediate role + mutation remains valid only for already-accepted members, and owner transfer must fail closed unless the + target user is already a stored member. That same shared auth boundary also owns pre-auth local setup and recovery containment. When no authentication is configured, anonymous fallback and bootstrap quick setup may run only on direct loopback, recovery tokens must bind to the generating client IP, and recovery may mint only a browser-bound localhost session rather than a shared diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 425ee645e..434880a99 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -192,6 +192,18 @@ Community limit enforcement. trial-only shortcut. 20. Add contract tests where runtime and pricing need to stay aligned 21. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts` + That same hosted bootstrap boundary also owns the runtime-capability JSON + shape that the app shell consumes before it decides whether organization + chrome and multi-tenant routes exist. `pkg/licensing/entitlement_payload.go` + must preserve empty `capabilities` and `limits` as JSON arrays, and + `frontend-modern/src/useAppRuntimeState.ts` plus the shared license API + adapter must treat those collections as canonical arrays rather than + letting `null` collapse hosted browser bootstrap into a free-tier fallback + that hides organization settings or discards a valid org context. + Organization membership changes that arrive through the self-hosted + invitation flow must therefore refresh org bootstrap through the shared + `organizations_changed` app-shell path instead of forking a second hosted + org bootstrap or pricing-aware shell reload. 22. Keep the hosted account portal shell task-first and compact. Section headers, billing action rows, and the maintained portal bundle under `internal/cloudcp/portal/` may surface the facts an operator needs, but the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3ff3c6921..f36d3ea7c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -180,6 +180,16 @@ work extends shared components instead of creating new local variants. session presentation policy says the operator cannot manage setup, `/settings` and sidebar navigation must land on the canonical reporting/control surface instead of setup-oriented install routes. + That same settings-shell boundary also owns explicit organization-route + stability. Deep links such as `/settings/organization`, + `/settings/organization/access`, and adjacent organization shells must keep + their canonical header and page frame when the route itself is allowed, + even while runtime capabilities or presentation policy are still settling. + Shared shell filtering may hide the sidebar item until the governing state + resolves, but `settingsHeaderMeta.ts`, `useSettingsAccess.ts`, and the + canonical settings-shell tests must not bounce an allowed organization + route back to `Infrastructure` just because nav filtering has not yet + surfaced that tab. That same shared session-presentation boundary also owns alerts read-only posture: `/alerts` may continue exposing reporting tabs such as overview and history, but activation controls plus configuration routes must collapse out diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 3b1e8c264..6c4ab547c 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -25,36 +25,37 @@ create cross-organization shares. 2. `frontend-modern/src/api/rbac.ts` 3. `frontend-modern/src/components/Settings/OrganizationAccessLoadingState.tsx` 4. `frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx` -5. `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx` -6. `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx` -7. `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx` -8. `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx` -9. `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx` -10. `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx` -11. `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx` -12. `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx` -13. `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx` -14. `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx` -15. `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx` -16. `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx` -17. `frontend-modern/src/components/Settings/RolesEditorDialog.tsx` -18. `frontend-modern/src/components/Settings/RolesPanel.tsx` -19. `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts` -20. `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts` -21. `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts` -22. `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx` -23. `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx` -24. `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts` -25. `frontend-modern/src/components/Settings/useRolesPanelState.ts` -26. `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts` -27. `frontend-modern/src/utils/organizationRolePresentation.ts` -28. `frontend-modern/src/utils/organizationSettingsPresentation.ts` -29. `frontend-modern/src/utils/orgUtils.ts` -30. `internal/api/access_control_handlers.go` -31. `internal/api/enterprise_extension_rbac_admin.go` -32. `internal/api/org_handlers.go` -33. `internal/api/org_lifecycle_handlers.go` -34. `internal/models/organization.go` +5. `frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx` +6. `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx` +7. `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx` +8. `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx` +9. `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx` +10. `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx` +11. `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx` +12. `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx` +13. `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx` +14. `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx` +15. `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx` +16. `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx` +17. `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx` +18. `frontend-modern/src/components/Settings/RolesEditorDialog.tsx` +19. `frontend-modern/src/components/Settings/RolesPanel.tsx` +20. `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts` +21. `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts` +22. `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts` +23. `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx` +24. `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx` +25. `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts` +26. `frontend-modern/src/components/Settings/useRolesPanelState.ts` +27. `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts` +28. `frontend-modern/src/utils/organizationRolePresentation.ts` +29. `frontend-modern/src/utils/organizationSettingsPresentation.ts` +30. `frontend-modern/src/utils/orgUtils.ts` +31. `internal/api/access_control_handlers.go` +32. `internal/api/enterprise_extension_rbac_admin.go` +33. `internal/api/org_handlers.go` +34. `internal/api/org_lifecycle_handlers.go` +35. `internal/models/organization.go` ## Shared Boundaries @@ -68,7 +69,7 @@ create cross-organization shares. ## Extension Points 1. Add or change organization role and share semantics through `internal/models/organization.go` -2. Add or change organization access, overview, sharing, RBAC feature-gating, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx`, `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx`, `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts`, `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`, `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`, `frontend-modern/src/components/Settings/useRolesPanelState.ts`, `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`, `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`, and `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts` +2. Add or change organization access, overview, sharing, RBAC feature-gating, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx`, `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx`, `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts`, `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`, `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`, `frontend-modern/src/components/Settings/useRolesPanelState.ts`, `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`, `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`, and `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts` 3. Route organization and RBAC frontend transport changes through `frontend-modern/src/api/orgs.ts` and `frontend-modern/src/api/rbac.ts` 4. Keep backend organization management and lifecycle handlers aligned through `internal/api/org_handlers.go` and `internal/api/org_lifecycle_handlers.go` 5. Keep RBAC role, assignment, and admin recovery transport aligned through `internal/api/access_control_handlers.go` and `internal/api/enterprise_extension_rbac_admin.go` @@ -118,6 +119,15 @@ That same canonical comparator now governs live membership transitions too: promoting or demoting a member must immediately change whether that user can manage organization settings, and organization listing must not leak non-member tenants just because another org with the same user exists in the system. +That same owned membership boundary now requires explicit invitation acceptance +for new self-hosted org access. `internal/api/org_handlers.go` may update an +existing member's role immediately, but inviting a new `UserID` must persist a +pending invitation until that same authenticated user accepts it through the +canonical invitation transport. Owner transfer must fail closed unless the +target user is already an accepted member, and the access panel must surface +both the current user's inbox and manager-visible pending invitations through +the dedicated invitation section owner rather than binding membership as soon as +an admin types a username. Incoming share visibility is part of that same boundary as well: a recipient must only see inbound shares whose requested `accessRole` is satisfied by the user's effective membership role in the target organization, using the shared diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 0fd07ab77..3f3151819 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -224,6 +224,12 @@ regression protection. `/api/state` and paying an avoidable `401`; once a local-login hint exists or the operator is on a protected route, that shared state probe remains the canonical runtime detector. + Invitation accept/revoke and other org-membership changes follow that same + hot-path contract: `frontend-modern/src/useAppRuntimeState.ts` may reload + the org list from the shared `organizations_changed` event, but that refresh + must stay event-driven and route-safe rather than expanding into a second + full app bootstrap, a pre-auth org probe, or a dashboard-route prewarm + that duplicates the canonical summary fetch path. into another summary-fetch or org-bootstrap hot path. The same protected hot path also owns Patrol route compatibility: if `frontend-modern/src/App.tsx` keeps `/ai` as a legacy alias while `/patrol` diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d9aef481c..1f762c6c2 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -818,6 +818,14 @@ pre-existing owner/member record, not a freshly minted browser cookie created by appending missing members or upgrading roles from the handoff token. Missing tenant membership, blank-owner orgs, and role-escalation claims must all fail closed before protected recovery routes load. +That same shared `internal/api/` organization boundary also assumes self-hosted +org access changes require invited-user consent before recovery-adjacent +routes treat the operator as a tenant member. Recovery settings and related +storage surfaces may observe `/api/orgs/{id}/members` mutations, but manager +submissions for a new `userId` must stay pending invitations until the +invited account explicitly accepts. Recovery-adjacent owner transfer therefore +remains restricted to existing members and may not be satisfied by an +unaccepted invitation record or a guessed account identifier. That shared `internal/api/` dependency now also assumes hosted tenant AI bootstrap and chat-runtime reads resolve through one effective hosted billing lease before storage- or recovery-adjacent runtime consumers inspect diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index f1d92533e..894b89aff 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -144,6 +144,13 @@ describe('App architecture', () => { expect(appRuntimeStateSource).toContain("const checkBackendHealth = async () => {"); expect(appRuntimeStateSource).toContain('const loadOrganizations = async () =>'); expect(appRuntimeStateSource).toContain('const handleOrgSwitch = (nextOrgID: string) =>'); + expect(appRuntimeStateSource).toContain('const handleOrganizationsChanged = () => {'); + expect(appRuntimeStateSource).toContain( + "eventBus.on('organizations_changed', handleOrganizationsChanged);", + ); + expect(appRuntimeStateSource).toContain( + "eventBus.off('organizations_changed', handleOrganizationsChanged);", + ); expect(appRuntimeStateSource).toContain( "import {\n isHostedModeEnabled,\n isMultiTenantEnabled,\n runtimeCapabilitiesLoaded,\n loadRuntimeCapabilities,\n} from '@/stores/license';", ); diff --git a/frontend-modern/src/api/__tests__/license.test.ts b/frontend-modern/src/api/__tests__/license.test.ts index 7bfe67f5c..3c2c93dbd 100644 --- a/frontend-modern/src/api/__tests__/license.test.ts +++ b/frontend-modern/src/api/__tests__/license.test.ts @@ -45,6 +45,20 @@ describe('LicenseAPI', () => { }); }); + it('normalizes null runtime capability collections to arrays', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + capabilities: null, + limits: null, + hosted_mode: false, + max_history_days: 7, + }); + + const result = await LicenseAPI.getRuntimeCapabilities(); + + expect(result.capabilities).toEqual([]); + expect(result.limits).toEqual([]); + }); + it('reads commercial entitlements from the commercial endpoint', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ tier: 'pro', diff --git a/frontend-modern/src/api/__tests__/orgs.test.ts b/frontend-modern/src/api/__tests__/orgs.test.ts index 865f42399..7aaa38b4e 100644 --- a/frontend-modern/src/api/__tests__/orgs.test.ts +++ b/frontend-modern/src/api/__tests__/orgs.test.ts @@ -98,10 +98,41 @@ describe('OrgsAPI', () => { }); }); + describe('listPendingInvitations', () => { + it('fetches pending organization invitations', async () => { + const mockInvitations = [{ userId: 'user-1', role: 'viewer' }]; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockInvitations); + + const result = await OrgsAPI.listPendingInvitations('org-1'); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/orgs/org-1/invitations', { + skipOrgContext: true, + }); + expect(result).toEqual(mockInvitations); + }); + }); + + describe('listMyInvitations', () => { + it('fetches the current user invitation inbox', async () => { + const mockInvitations = [{ orgId: 'org-1', orgDisplayName: 'Org 1', userId: 'user-1' }]; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockInvitations); + + const result = await OrgsAPI.listMyInvitations(); + + expect(apiFetchJSON).toHaveBeenCalledWith('/api/org-invitations', { + skipOrgContext: true, + }); + expect(result).toEqual(mockInvitations); + }); + }); + describe('inviteMember', () => { it('invites a member to organization', async () => { - const mockMember = { userId: 'user-1', role: 'viewer' }; - vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockMember); + const mockInvitation = { + kind: 'invitation', + invitation: { userId: 'user-1', role: 'viewer' }, + }; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockInvitation); const result = await OrgsAPI.inviteMember('org-1', { userId: 'user-1', role: 'viewer' }); @@ -113,10 +144,60 @@ describe('OrgsAPI', () => { skipOrgContext: true, }), ); + expect(result).toEqual(mockInvitation); + }); + }); + + describe('acceptMyInvitation', () => { + it('accepts an invitation for the current user', async () => { + const mockMember = { kind: 'member', member: { userId: 'user-1', role: 'viewer' } }; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockMember); + + const result = await OrgsAPI.acceptMyInvitation('org-1'); + + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/org-invitations/org-1/accept', + expect.objectContaining({ + method: 'POST', + skipOrgContext: true, + }), + ); expect(result).toEqual(mockMember); }); }); + describe('declineMyInvitation', () => { + it('declines an invitation for the current user', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce(undefined); + + await OrgsAPI.declineMyInvitation('org-1'); + + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/org-invitations/org-1', + expect.objectContaining({ + method: 'DELETE', + skipOrgContext: true, + }), + ); + }); + }); + + describe('revokeInvitation', () => { + it('revokes a pending invitation from an organization', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce(undefined); + + await OrgsAPI.revokeInvitation('org-1', 'user-1'); + + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/orgs/org-1/invitations/user-1', + expect.objectContaining({ + method: 'DELETE', + skipOrgContext: true, + }), + ); + }); + }); + describe('removeMember', () => { it('removes a member from organization', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce(undefined); diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts index c079a9fc7..08791a78a 100644 --- a/frontend-modern/src/api/license.ts +++ b/frontend-modern/src/api/license.ts @@ -129,6 +129,28 @@ export interface LicenseFeatureStatus { upgrade_url: string; } +function normalizeRuntimeCapabilities( + payload: unknown, +): LicenseRuntimeCapabilities { + const source = + payload && typeof payload === 'object' + ? (payload as Partial) + : {}; + + return { + ...source, + capabilities: Array.isArray(source.capabilities) + ? source.capabilities.filter((value): value is string => typeof value === 'string') + : [], + limits: Array.isArray(source.limits) + ? source.limits.filter( + (value): value is EntitlementLimitStatus => + Boolean(value) && typeof value === 'object', + ) + : [], + }; +} + export class LicenseAPI { private static baseUrl = '/api/license'; @@ -137,9 +159,8 @@ export class LicenseAPI { } static async getRuntimeCapabilities(): Promise { - return apiFetchJSON( - `${this.baseUrl}/runtime-capabilities`, - ) as Promise; + const payload = await apiFetchJSON(`${this.baseUrl}/runtime-capabilities`); + return normalizeRuntimeCapabilities(payload); } static async getCommercialPosture(): Promise { diff --git a/frontend-modern/src/api/orgs.ts b/frontend-modern/src/api/orgs.ts index 0b881e1d3..33118d031 100644 --- a/frontend-modern/src/api/orgs.ts +++ b/frontend-modern/src/api/orgs.ts @@ -9,6 +9,24 @@ export interface OrganizationMember { addedBy?: string; } +export interface OrganizationInvitation { + userId: string; + role: Exclude; + invitedAt: string; + invitedBy: string; +} + +export interface OrganizationAccessMutationResult { + kind: 'member' | 'invitation'; + member?: OrganizationMember; + invitation?: OrganizationInvitation; +} + +export interface UserOrganizationInvitation extends OrganizationInvitation { + orgId: string; + orgDisplayName: string; +} + export interface Organization { id: string; displayName: string; @@ -69,19 +87,62 @@ export const OrgsAPI = { skipOrgContext: true, }), + listPendingInvitations: (id: string) => + apiFetchJSON( + `/api/orgs/${encodeURIComponent(id)}/invitations`, + { + skipOrgContext: true, + }, + ), + + listMyInvitations: () => + apiFetchJSON('/api/org-invitations', { + skipOrgContext: true, + }), + inviteMember: (id: string, payload: { userId: string; role: OrganizationRole }) => - apiFetchJSON(`/api/orgs/${encodeURIComponent(id)}/members`, { - method: 'POST', - body: JSON.stringify(payload), + apiFetchJSON( + `/api/orgs/${encodeURIComponent(id)}/members`, + { + method: 'POST', + body: JSON.stringify(payload), + skipOrgContext: true, + }, + ), + + acceptMyInvitation: (id: string) => + apiFetchJSON( + `/api/org-invitations/${encodeURIComponent(id)}/accept`, + { + method: 'POST', + skipOrgContext: true, + }, + ), + + declineMyInvitation: (id: string) => + apiFetchJSON(`/api/org-invitations/${encodeURIComponent(id)}`, { + method: 'DELETE', skipOrgContext: true, }), updateMemberRole: (id: string, payload: { userId: string; role: OrganizationRole }) => - apiFetchJSON(`/api/orgs/${encodeURIComponent(id)}/members`, { - method: 'POST', - body: JSON.stringify(payload), - skipOrgContext: true, - }), + apiFetchJSON( + `/api/orgs/${encodeURIComponent(id)}/members`, + { + method: 'POST', + body: JSON.stringify(payload), + skipOrgContext: true, + }, + ), + + revokeInvitation: (id: string, userId: string) => + apiFetchJSON( + `/api/orgs/${encodeURIComponent(id)}/invitations/${encodeURIComponent(userId)}`, + { + method: 'DELETE', + skipOrgContext: true, + }, + ), removeMember: (id: string, userId: string) => apiFetchJSON( diff --git a/frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx b/frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx new file mode 100644 index 000000000..83a4b3826 --- /dev/null +++ b/frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx @@ -0,0 +1,103 @@ +import { Component, For, Show } from 'solid-js'; +import { formatOrgDate, roleBadgeClass } from '@/utils/orgUtils'; +import type { useOrganizationAccessPanelState } from './useOrganizationAccessPanelState'; + +interface OrganizationAccessInvitationsSectionProps { + state: ReturnType; +} + +export const OrganizationAccessInvitationsSection: Component< + OrganizationAccessInvitationsSectionProps +> = (props) => ( +
+ 0}> +
+
+

Your Invitations

+

Accept or decline pending organization access.

+
+
+ + {(invitation) => ( +
+
+
+
+ {invitation.orgDisplayName || invitation.orgId} +
+
+ Invited by {invitation.invitedBy || 'an admin'} on{' '} + {formatOrgDate(invitation.invitedAt)} +
+
+ + {invitation.role} + +
+
+ + +
+
+ )} +
+
+
+
+ + 0}> +
+
+

Pending Invitations

+

These users still need to accept access.

+
+
+ + {(invitation) => ( +
+
+
{invitation.userId}
+
+ Invited by {invitation.invitedBy || 'an admin'} on{' '} + {formatOrgDate(invitation.invitedAt)} +
+
+
+ + {invitation.role} + + +
+
+ )} +
+
+
+
+
+); diff --git a/frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx b/frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx index 91272c5fa..b72f3fe05 100644 --- a/frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx +++ b/frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx @@ -17,7 +17,7 @@ export const OrganizationAccessManagementSection: Component< <>
-

Add Member

+

Invite Member

- {props.state.saving() ? 'Saving...' : 'Add'} + {props.state.saving() ? 'Saving...' : 'Invite'}
diff --git a/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx b/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx index 75547616a..dfedc8544 100644 --- a/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx +++ b/frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx @@ -8,6 +8,7 @@ import { } from '@/utils/organizationSettingsPresentation'; import Users from 'lucide-solid/icons/users'; import { OrganizationAccessLoadingState } from './OrganizationAccessLoadingState'; +import { OrganizationAccessInvitationsSection } from './OrganizationAccessInvitationsSection'; import { OrganizationAccessManagementSection } from './OrganizationAccessManagementSection'; import { OrganizationAccessMembersSection } from './OrganizationAccessMembersSection'; import { useOrganizationAccessPanelState } from './useOrganizationAccessPanelState'; @@ -33,12 +34,13 @@ export const OrganizationAccessPanel: Component =
} bodyClass="space-y-5" > }> + diff --git a/frontend-modern/src/components/Settings/__tests__/OrganizationAccessPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/OrganizationAccessPanel.test.tsx index a21d519be..21b1860ca 100644 --- a/frontend-modern/src/components/Settings/__tests__/OrganizationAccessPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/OrganizationAccessPanel.test.tsx @@ -5,22 +5,33 @@ import organizationAccessStateSource from '../useOrganizationAccessPanelState.ts const orgGetMock = vi.fn(); const listMembersMock = vi.fn(); +const listPendingInvitationsMock = vi.fn(); +const listMyInvitationsMock = vi.fn(); const updateMemberRoleMock = vi.fn(); const inviteMemberMock = vi.fn(); +const acceptMyInvitationMock = vi.fn(); +const declineMyInvitationMock = vi.fn(); +const revokeInvitationMock = vi.fn(); const removeMemberMock = vi.fn(); const isMultiTenantEnabledMock = vi.fn(); const getOrgIDMock = vi.fn(); const notificationSuccessMock = vi.fn(); const notificationErrorMock = vi.fn(); const eventBusOnMock = vi.fn(); +const eventBusEmitMock = vi.fn(); const loggerErrorMock = vi.fn(); vi.mock('@/api/orgs', () => ({ OrgsAPI: { get: (...args: unknown[]) => orgGetMock(...args), listMembers: (...args: unknown[]) => listMembersMock(...args), + listPendingInvitations: (...args: unknown[]) => listPendingInvitationsMock(...args), + listMyInvitations: (...args: unknown[]) => listMyInvitationsMock(...args), updateMemberRole: (...args: unknown[]) => updateMemberRoleMock(...args), inviteMember: (...args: unknown[]) => inviteMemberMock(...args), + acceptMyInvitation: (...args: unknown[]) => acceptMyInvitationMock(...args), + declineMyInvitation: (...args: unknown[]) => declineMyInvitationMock(...args), + revokeInvitation: (...args: unknown[]) => revokeInvitationMock(...args), removeMember: (...args: unknown[]) => removeMemberMock(...args), }, })); @@ -43,6 +54,7 @@ vi.mock('@/stores/notifications', () => ({ vi.mock('@/stores/events', () => ({ eventBus: { on: (...args: unknown[]) => eventBusOnMock(...args), + emit: (...args: unknown[]) => eventBusEmitMock(...args), }, })); @@ -86,14 +98,20 @@ const deferred = () => { beforeEach(() => { orgGetMock.mockReset(); listMembersMock.mockReset(); + listPendingInvitationsMock.mockReset(); + listMyInvitationsMock.mockReset(); updateMemberRoleMock.mockReset(); inviteMemberMock.mockReset(); + acceptMyInvitationMock.mockReset(); + declineMyInvitationMock.mockReset(); + revokeInvitationMock.mockReset(); removeMemberMock.mockReset(); isMultiTenantEnabledMock.mockReset(); getOrgIDMock.mockReset(); notificationSuccessMock.mockReset(); notificationErrorMock.mockReset(); eventBusOnMock.mockReset(); + eventBusEmitMock.mockReset(); loggerErrorMock.mockReset(); isMultiTenantEnabledMock.mockReturnValue(true); @@ -102,8 +120,13 @@ beforeEach(() => { orgGetMock.mockResolvedValue(baseOrg); listMembersMock.mockResolvedValue(baseMembers); + listPendingInvitationsMock.mockResolvedValue([]); + listMyInvitationsMock.mockResolvedValue([]); updateMemberRoleMock.mockResolvedValue(undefined); - inviteMemberMock.mockResolvedValue(undefined); + inviteMemberMock.mockResolvedValue({ kind: 'invitation' }); + acceptMyInvitationMock.mockResolvedValue({ kind: 'member' }); + declineMyInvitationMock.mockResolvedValue(undefined); + revokeInvitationMock.mockResolvedValue(undefined); removeMemberMock.mockResolvedValue(undefined); }); @@ -119,12 +142,12 @@ describe('OrganizationAccessPanel', () => { const { container } = renderPanel(); expect(container.querySelector('.animate-pulse')).toBeTruthy(); - expect(screen.queryByRole('heading', { name: 'Add Member' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Invite Member' })).not.toBeInTheDocument(); orgDeferred.resolve(baseOrg); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Add Member' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Invite Member' })).toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index ad8fb0cad..16884ad49 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -35,6 +35,10 @@ describe('settings architecture guardrails', () => { expect(settingsHeaderMetaSource).toContain( 'Review monitored systems and add new infrastructure to Pulse', ); + expect(settingsHeaderMetaSource).toContain("'organization-access': {"); + expect(settingsHeaderMetaSource).toContain( + 'Manage organization invitations, member roles, and ownership transfers.', + ); expect(settingsNavigationHookSource).toContain('deriveAddStepFromLegacyPath(path)'); expect(settingsNavigationHookSource).toContain( @@ -59,6 +63,13 @@ describe('settings architecture guardrails', () => { ); }); + it('keeps allowed organization deep links on the canonical settings shell', () => { + expect(settingsSource).toContain("import { useSettingsAccess } from './useSettingsAccess';"); + expect(settingsSource).toContain('const activeSettingsPanelEntry = createMemo(() => {'); + expect(settingsSource).toContain('if (!flatTabs().some((tab) => tab.id === currentTab)) {'); + expect(settingsSource).toContain('return settingsPanelRegistry()[currentTab];'); + }); + it('keeps the infrastructure add flow inline on ConnectionEditor instead of retired overlays', () => { expect(infrastructureWorkspaceSource).toContain( "import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';", diff --git a/frontend-modern/src/components/Settings/__tests__/useSettingsAccess.test.tsx b/frontend-modern/src/components/Settings/__tests__/useSettingsAccess.test.tsx new file mode 100644 index 000000000..863312c13 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/useSettingsAccess.test.tsx @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { useSettingsAccess } from '../useSettingsAccess'; + +const hasFeatureMock = vi.fn(); +const runtimeCapabilitiesLoadedMock = vi.fn(); +const isHostedModeEnabledMock = vi.fn(); +const presentationPolicyHidesCommercialSurfacesMock = vi.fn(); +const presentationPolicyHidesOrganizationSurfacesMock = vi.fn(); +const presentationPolicyIsDemoModeMock = vi.fn(); +const presentationPolicyIsReadOnlyMock = vi.fn(); +const sessionPresentationPolicyResolvedMock = vi.fn(); +const shouldHideSettingsNavItemMock = vi.fn(); + +vi.mock('@/stores/license', () => ({ + hasFeature: (...args: unknown[]) => hasFeatureMock(...args), + isHostedModeEnabled: (...args: unknown[]) => isHostedModeEnabledMock(...args), + runtimeCapabilitiesLoaded: (...args: unknown[]) => runtimeCapabilitiesLoadedMock(...args), +})); + +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyHidesCommercialSurfaces: (...args: unknown[]) => + presentationPolicyHidesCommercialSurfacesMock(...args), + presentationPolicyHidesOrganizationSurfaces: (...args: unknown[]) => + presentationPolicyHidesOrganizationSurfacesMock(...args), + presentationPolicyIsDemoMode: (...args: unknown[]) => presentationPolicyIsDemoModeMock(...args), + presentationPolicyIsReadOnly: (...args: unknown[]) => presentationPolicyIsReadOnlyMock(...args), + sessionPresentationPolicyResolved: (...args: unknown[]) => + sessionPresentationPolicyResolvedMock(...args), + syncSessionPresentationPolicy: vi.fn(), +})); + +vi.mock('../settingsNavVisibility', () => ({ + shouldHideSettingsNavItem: (...args: unknown[]) => shouldHideSettingsNavItemMock(...args), +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +function renderHarness(setActiveTabSpy: (tab: string) => void, initialTab = 'organization-access') { + return render(() => { + const [activeTab, setActiveTab] = createSignal(initialTab as never); + useSettingsAccess({ + activeTab, + setActiveTab: (tab) => { + setActiveTabSpy(tab); + setActiveTab(tab as never); + }, + searchQuery: () => '', + }); + return null; + }); +} + +describe('useSettingsAccess', () => { + beforeEach(() => { + hasFeatureMock.mockReset(); + runtimeCapabilitiesLoadedMock.mockReset(); + isHostedModeEnabledMock.mockReset(); + presentationPolicyHidesCommercialSurfacesMock.mockReset(); + presentationPolicyHidesOrganizationSurfacesMock.mockReset(); + presentationPolicyIsDemoModeMock.mockReset(); + presentationPolicyIsReadOnlyMock.mockReset(); + sessionPresentationPolicyResolvedMock.mockReset(); + shouldHideSettingsNavItemMock.mockReset(); + + hasFeatureMock.mockImplementation((feature: string) => feature === 'multi_tenant'); + runtimeCapabilitiesLoadedMock.mockReturnValue(true); + isHostedModeEnabledMock.mockReturnValue(false); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false); + presentationPolicyHidesOrganizationSurfacesMock.mockReturnValue(false); + presentationPolicyIsDemoModeMock.mockReturnValue(false); + presentationPolicyIsReadOnlyMock.mockReturnValue(false); + sessionPresentationPolicyResolvedMock.mockReturnValue(true); + shouldHideSettingsNavItemMock.mockImplementation( + (tab: string) => tab === 'organization-access', + ); + }); + + afterEach(() => { + cleanup(); + }); + + it('keeps an explicit organization route active when the tab contract still allows it', async () => { + const setActiveTabSpy = vi.fn(); + + renderHarness(setActiveTabSpy); + + await waitFor(() => { + expect(setActiveTabSpy).not.toHaveBeenCalled(); + }); + }); + + it('falls back to the default tab when the current route is no longer allowed', async () => { + const setActiveTabSpy = vi.fn(); + hasFeatureMock.mockReturnValue(false); + + renderHarness(setActiveTabSpy); + + await waitFor(() => { + expect(setActiveTabSpy).toHaveBeenCalledWith('infrastructure-systems'); + }); + }); +}); diff --git a/frontend-modern/src/components/Settings/settingsHeaderMeta.ts b/frontend-modern/src/components/Settings/settingsHeaderMeta.ts index 5d8d80cb8..76e02477f 100644 --- a/frontend-modern/src/components/Settings/settingsHeaderMeta.ts +++ b/frontend-modern/src/components/Settings/settingsHeaderMeta.ts @@ -57,7 +57,7 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = { }, 'organization-access': { title: 'Organization Access', - description: 'Manage organization members, roles, and ownership transfers.', + description: 'Manage organization invitations, member roles, and ownership transfers.', }, 'organization-sharing': { title: 'Organization Sharing', diff --git a/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts b/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts index a43e4920d..c39171915 100644 --- a/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts +++ b/frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts @@ -2,8 +2,10 @@ import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { OrgsAPI, type Organization, + type OrganizationInvitation, type OrganizationMember, type OrganizationRole, + type UserOrganizationInvitation, } from '@/api/orgs'; import { eventBus } from '@/stores/events'; import { isMultiTenantEnabled } from '@/stores/license'; @@ -14,9 +16,17 @@ import { logger } from '@/utils/logger'; import { normalizeOrgScope } from '@/utils/orgScope'; import { getOrganizationAddMemberErrorMessage, + getOrganizationAccessInvitationAcceptedMessage, + getOrganizationAccessInvitationDeclinedMessage, + getOrganizationAccessInvitationRevokedMessage, + getOrganizationAccessInvitationSentMessage, getOrganizationAccessMemberAddedMessage, getOrganizationAccessMemberRemovedMessage, + getOrganizationAccessOwnerTransferMemberRequiredMessage, + getOrganizationAccessPendingInvitationsEmptyState, getOrganizationAccessRoleUpdatedMessage, + getOrganizationAccessYourInvitationsEmptyState, + getOrganizationInvitationActionErrorMessage, getOrganizationMemberRoleUpdateErrorMessage, getOrganizationMemberRemoveConfirmMessage, getOrganizationMemberUserIdRequiredMessage, @@ -35,6 +45,8 @@ export function useOrganizationAccessPanelState(props: OrganizationAccessPanelPr const [saving, setSaving] = createSignal(false); const [org, setOrg] = createSignal(null); const [members, setMembers] = createSignal([]); + const [pendingInvitations, setPendingInvitations] = createSignal([]); + const [myInvitations, setMyInvitations] = createSignal([]); const [inviteUserID, setInviteUserID] = createSignal(''); const [inviteRole, setInviteRole] = createSignal('viewer'); @@ -45,12 +57,17 @@ export function useOrganizationAccessPanelState(props: OrganizationAccessPanelPr setLoading(true); try { const orgId = activeOrgId(); - const [orgData, memberData] = await Promise.all([ - OrgsAPI.get(orgId), + const orgData = await OrgsAPI.get(orgId); + const manageable = canManageOrg(orgData, props.currentUser); + const [memberData, invitationData, myInvitationData] = await Promise.all([ OrgsAPI.listMembers(orgId), + manageable ? OrgsAPI.listPendingInvitations(orgId) : Promise.resolve([]), + OrgsAPI.listMyInvitations(), ]); setOrg(orgData); setMembers(memberData); + setPendingInvitations(invitationData); + setMyInvitations(myInvitationData); } catch (error) { logger.error('Failed to load organization access data', error); const message = error instanceof Error ? error.message : ''; @@ -101,8 +118,17 @@ export function useOrganizationAccessPanelState(props: OrganizationAccessPanelPr setSaving(true); try { const role = inviteRole(); - await OrgsAPI.inviteMember(currentOrg.id, { userId, role }); - notificationStore.success(getOrganizationAccessMemberAddedMessage(userId, role)); + if (role === 'owner' && !members().some((member) => member.userId === userId)) { + notificationStore.error(getOrganizationAccessOwnerTransferMemberRequiredMessage()); + return; + } + + const result = await OrgsAPI.inviteMember(currentOrg.id, { userId, role }); + if (result.kind === 'invitation') { + notificationStore.success(getOrganizationAccessInvitationSentMessage(userId, role)); + } else { + notificationStore.success(getOrganizationAccessMemberAddedMessage(userId, role)); + } setInviteUserID(''); setInviteRole('viewer'); await loadOrganizationAccess(); @@ -116,6 +142,68 @@ export function useOrganizationAccessPanelState(props: OrganizationAccessPanelPr } }; + const acceptInvitation = async (orgId: string) => { + setSaving(true); + try { + await OrgsAPI.acceptMyInvitation(orgId); + notificationStore.success( + getOrganizationAccessInvitationAcceptedMessage(props.currentUser || 'user'), + ); + eventBus.emit('organizations_changed'); + await loadOrganizationAccess(); + } catch (error) { + logger.error('Failed to accept organization invitation', error); + notificationStore.error( + getOrganizationInvitationActionErrorMessage( + error instanceof Error ? error.message : undefined, + ), + ); + } finally { + setSaving(false); + } + }; + + const declineInvitation = async (orgId: string) => { + setSaving(true); + try { + await OrgsAPI.declineMyInvitation(orgId); + notificationStore.success(getOrganizationAccessInvitationDeclinedMessage(orgId)); + eventBus.emit('organizations_changed'); + await loadOrganizationAccess(); + } catch (error) { + logger.error('Failed to decline organization invitation', error); + notificationStore.error( + getOrganizationInvitationActionErrorMessage( + error instanceof Error ? error.message : undefined, + ), + ); + } finally { + setSaving(false); + } + }; + + const revokeInvitation = async (userId: string) => { + const currentOrg = org(); + if (!currentOrg) return; + + setSaving(true); + try { + await OrgsAPI.revokeInvitation(currentOrg.id, userId); + notificationStore.success(getOrganizationAccessInvitationRevokedMessage(userId)); + eventBus.emit('organizations_changed'); + await loadOrganizationAccess(); + } catch (error) { + logger.error('Failed to revoke organization invitation', error); + notificationStore.error( + getOrganizationInvitationActionErrorMessage( + error instanceof Error ? error.message : undefined, + ), + ); + } finally { + setSaving(false); + } + }; + const removeMember = async (member: OrganizationMember) => { const currentOrg = org(); if (!currentOrg) return; @@ -151,21 +239,34 @@ export function useOrganizationAccessPanelState(props: OrganizationAccessPanelPr const unsubscribe = eventBus.on('org_switched', () => { void loadOrganizationAccess(); }); - onCleanup(unsubscribe); + const unsubscribeOrganizationsChanged = eventBus.on('organizations_changed', () => { + void loadOrganizationAccess(); + }); + onCleanup(() => { + unsubscribe(); + unsubscribeOrganizationsChanged(); + }); }); return { + acceptInvitation, canManageCurrentOrg, + declineInvitation, inviteMember, inviteRole, inviteUserID, loading, members, + myInvitations, org, + pendingInvitations, removeMember, + revokeInvitation, saving, setInviteRole, setInviteUserID, updateRole, + pendingInvitationsEmptyState: getOrganizationAccessPendingInvitationsEmptyState, + yourInvitationsEmptyState: getOrganizationAccessYourInvitationsEmptyState, }; } diff --git a/frontend-modern/src/components/Settings/useSettingsAccess.ts b/frontend-modern/src/components/Settings/useSettingsAccess.ts index 754e26f95..52b6539b1 100644 --- a/frontend-modern/src/components/Settings/useSettingsAccess.ts +++ b/frontend-modern/src/components/Settings/useSettingsAccess.ts @@ -130,12 +130,14 @@ export function useSettingsAccess({ createEffect(() => { const current = activeTab(); + const currentItem = getSettingsNavItem(current); const requiresFeatureResolution = Boolean(tabFeatureRequirements[current]?.length); - const requiresCapabilityResolution = Boolean(getSettingsNavItem(current)?.requiredCapability); + const requiresCapabilityResolution = Boolean(currentItem?.requiredCapability); const requiresPresentationPolicyResolution = Boolean( - getSettingsNavItem(current)?.hideWhenCommercialHidden || - getSettingsNavItem(current)?.hideWhenOrganizationHidden || - getSettingsNavItem(current)?.hideWhenReadOnly, + currentItem?.hideWhenCommercialHidden || + currentItem?.hideWhenOrganizationHidden || + currentItem?.hideWhenReadOnly || + currentItem?.hideWhenDemoMode, ); if ( (requiresFeatureResolution && !runtimeCapabilitiesLoaded()) || @@ -146,6 +148,51 @@ export function useSettingsAccess({ } if (!flatTabs().some((tab) => tab.id === current)) { + const currentRouteStillAllowed = (() => { + if (!currentItem) { + return false; + } + + if (currentItem.hostedOnly && !isHostedModeEnabled()) { + return false; + } + + if (currentItem.hideWhenOrganizationHidden && organizationSurfacesHidden()) { + return false; + } + if (currentItem.hideWhenCommercialHidden && commercialSurfacesHidden()) { + return false; + } + if (currentItem.hideWhenDemoMode && demoMode()) { + return false; + } + if (currentItem.hideWhenReadOnly && readOnly()) { + return false; + } + + const settingsCapabilities = securityStatus()?.settingsCapabilities ?? null; + const settingsCapabilitiesResolved = securityStatus() !== null; + if ( + currentItem.requiredCapability && + settingsCapabilitiesResolved && + settingsCapabilities?.[currentItem.requiredCapability] !== true + ) { + return false; + } + + if (currentItem.hideWhenUnavailable) { + const requiredFeatures = currentItem.features ?? []; + if (!requiredFeatures.every((feature) => hasFeature(feature))) { + return false; + } + } + + return true; + })(); + + if (currentRouteStillAllowed) { + return; + } setActiveTab(DEFAULT_SETTINGS_TAB); } }); diff --git a/frontend-modern/src/stores/events.ts b/frontend-modern/src/stores/events.ts index 247f54f39..758c2c426 100644 --- a/frontend-modern/src/stores/events.ts +++ b/frontend-modern/src/stores/events.ts @@ -10,7 +10,8 @@ export type EventType = | 'ai_discovery_progress' | 'theme_changed' | 'websocket_reconnected' - | 'org_switched'; + | 'org_switched' + | 'organizations_changed'; // Event data types export interface NodeAutoRegisteredData { @@ -58,6 +59,7 @@ export type EventDataMap = { theme_changed: string; // 'light' or 'dark' websocket_reconnected: void; // Emitted when WebSocket successfully reconnects org_switched: string; // The new org ID + organizations_changed: void; // Emitted when org membership/invitation state changes }; // Generic event handler diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 1e4166eef..39ea86171 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -602,13 +602,20 @@ export const useAppRuntimeState = () => { void alertsActivation.refreshActiveAlerts(); }; + const handleOrganizationsChanged = () => { + logger.info('Organization membership changed, refreshing organization list'); + void loadOrganizations(); + }; + eventBus.on('theme_changed', handleRemoteThemeChange); eventBus.on('websocket_reconnected', handleWebSocketReconnected); + eventBus.on('organizations_changed', handleOrganizationsChanged); onCleanup(() => { mediaQuery.removeEventListener('change', systemThemeListener); eventBus.off('theme_changed', handleRemoteThemeChange); eventBus.off('websocket_reconnected', handleWebSocketReconnected); + eventBus.off('organizations_changed', handleOrganizationsChanged); }); }); diff --git a/frontend-modern/src/utils/organizationSettingsPresentation.ts b/frontend-modern/src/utils/organizationSettingsPresentation.ts index d99f8cc9b..f5d11f664 100644 --- a/frontend-modern/src/utils/organizationSettingsPresentation.ts +++ b/frontend-modern/src/utils/organizationSettingsPresentation.ts @@ -36,6 +36,14 @@ export function getOrganizationAccessEmptyState(): string { return 'No organization members found.'; } +export function getOrganizationAccessPendingInvitationsEmptyState(): string { + return 'No pending invitations for this organization.'; +} + +export function getOrganizationAccessYourInvitationsEmptyState(): string { + return 'No invitations are waiting for you.'; +} + export function getOrganizationOverviewMembersEmptyState(): string { return 'No members found.'; } @@ -64,6 +72,10 @@ export function getOrganizationMemberUserIdRequiredMessage(): string { return 'User ID is required'; } +export function getOrganizationAccessOwnerTransferMemberRequiredMessage(): string { + return 'Ownership can only be transferred to an existing member.'; +} + export function getOrganizationAccessManageRequiredMessage(): string { return 'Admin or owner role required to manage organization access.'; } @@ -86,10 +98,33 @@ export function getOrganizationAccessMemberAddedMessage( return `Added ${userId} as ${role}.`; } +export function getOrganizationAccessInvitationSentMessage( + userId: string, + role: Exclude, +): string { + return `Sent ${userId} an invitation for the ${role} role.`; +} + +export function getOrganizationAccessInvitationAcceptedMessage(userId: string): string { + return `${userId} joined the organization.`; +} + +export function getOrganizationAccessInvitationDeclinedMessage(orgId: string): string { + return `Declined the invitation for ${orgId}.`; +} + +export function getOrganizationAccessInvitationRevokedMessage(userId: string): string { + return `Revoked ${userId}'s pending invitation.`; +} + export function getOrganizationAddMemberErrorMessage(message?: string): string { return message || 'Unable to add the member.'; } +export function getOrganizationInvitationActionErrorMessage(message?: string): string { + return message || 'Unable to update the invitation.'; +} + export function getOrganizationMemberRemoveConfirmMessage( userId: string, organizationName: string, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 47d9c243b..20b137a39 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5559,6 +5559,46 @@ func TestContract_SelfHostedCommunityEntitlementsJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_SelfHostedCommunityRuntimeCapabilitiesJSONSnapshot(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + handlers := NewLicenseHandlers(mtp, false) + + req := httptest.NewRequest(http.MethodGet, "/api/license/runtime-capabilities", nil). + WithContext(context.WithValue(context.Background(), OrgIDContextKey, "default")) + rec := httptest.NewRecorder() + handlers.HandleRuntimeCapabilities(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "runtime capabilities status=%d, want %d: %s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + + var payload RuntimeCapabilitiesPayload + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode runtime capabilities: %v", err) + } + + got, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal runtime capabilities payload: %v", err) + } + + const want = `{ + "capabilities":["update_alerts","sso","ai_patrol"], + "limits":[], + "hosted_mode":false, + "max_history_days":7, + "monitored_system_capacity":{"mode":"usage_unavailable","urgency":"ok","current":0,"limit":0,"current_available":false,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":false} + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(t *testing.T) { payload := buildEntitlementPayloadWithUsage(&licenseStatus{ Valid: true, @@ -7112,6 +7152,194 @@ func TestContract_APITokenDeleteRejectsScopeEscalation(t *testing.T) { } } +func TestContract_OrganizationInvitationTransportJSONSnapshot(t *testing.T) { + t.Setenv("PULSE_DEV", "true") + defer SetMultiTenantEnabled(false) + SetMultiTenantEnabled(true) + + persistence := config.NewMultiTenantPersistence(t.TempDir()) + h := NewOrgHandlers(persistence, nil) + + createReq := withUser( + httptest.NewRequest(http.MethodPost, "/api/orgs", bytes.NewBufferString(`{"id":"acme","displayName":"Acme"}`)), + "alice", + ) + createRec := httptest.NewRecorder() + h.HandleCreateOrg(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create org: status=%d body=%s", createRec.Code, createRec.Body.String()) + } + + inviteReq := withUser( + httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", bytes.NewBufferString(`{"userId":"bob","role":"viewer"}`)), + "alice", + ) + inviteReq.SetPathValue("id", "acme") + inviteRec := httptest.NewRecorder() + h.HandleInviteMember(inviteRec, inviteReq) + if inviteRec.Code != http.StatusAccepted { + t.Fatalf("invite member: status=%d body=%s", inviteRec.Code, inviteRec.Body.String()) + } + + var invitePayload organizationAccessMutationResponse + if err := json.Unmarshal(inviteRec.Body.Bytes(), &invitePayload); err != nil { + t.Fatalf("decode invite payload: %v", err) + } + if invitePayload.Kind != "invitation" || invitePayload.Invitation == nil { + t.Fatalf("unexpected invite payload: %+v", invitePayload) + } + if invitePayload.Invitation.InvitedAt.IsZero() { + t.Fatalf("expected invite payload to include invitedAt") + } + + inboxReq := withUser(httptest.NewRequest(http.MethodGet, "/api/org-invitations", nil), "bob") + inboxRec := httptest.NewRecorder() + h.HandleListMyInvitations(inboxRec, inboxReq) + if inboxRec.Code != http.StatusOK { + t.Fatalf("list my invitations: status=%d body=%s", inboxRec.Code, inboxRec.Body.String()) + } + + var inboxPayload []organizationUserInvitationResponse + if err := json.Unmarshal(inboxRec.Body.Bytes(), &inboxPayload); err != nil { + t.Fatalf("decode invitation inbox: %v", err) + } + if len(inboxPayload) != 1 { + t.Fatalf("expected 1 invitation, got %d", len(inboxPayload)) + } + if inboxPayload[0].InvitedAt.IsZero() { + t.Fatalf("expected inbox payload to include invitedAt") + } + + acceptReq := withUser(httptest.NewRequest(http.MethodPost, "/api/org-invitations/acme/accept", nil), "bob") + acceptReq.SetPathValue("id", "acme") + acceptRec := httptest.NewRecorder() + h.HandleAcceptMyInvitation(acceptRec, acceptReq) + if acceptRec.Code != http.StatusOK { + t.Fatalf("accept invitation: status=%d body=%s", acceptRec.Code, acceptRec.Body.String()) + } + + var acceptPayload organizationAccessMutationResponse + if err := json.Unmarshal(acceptRec.Body.Bytes(), &acceptPayload); err != nil { + t.Fatalf("decode accept payload: %v", err) + } + if acceptPayload.Kind != "member" || acceptPayload.Member == nil { + t.Fatalf("unexpected accept payload: %+v", acceptPayload) + } + if acceptPayload.Member.AddedAt.IsZero() { + t.Fatalf("expected accept payload to include addedAt") + } + + got, err := json.Marshal(struct { + Invite struct { + Kind string `json:"kind"` + Invitation struct { + UserID string `json:"userId"` + Role string `json:"role"` + InvitedAt string `json:"invitedAt"` + InvitedBy string `json:"invitedBy"` + } `json:"invitation"` + } `json:"invite"` + Inbox []struct { + UserID string `json:"userId"` + Role string `json:"role"` + InvitedAt string `json:"invitedAt"` + InvitedBy string `json:"invitedBy"` + OrgID string `json:"orgId"` + OrgDisplayName string `json:"orgDisplayName"` + } `json:"inbox"` + Accept struct { + Kind string `json:"kind"` + Member struct { + UserID string `json:"userId"` + Role string `json:"role"` + AddedAt string `json:"addedAt"` + AddedBy string `json:"addedBy"` + } `json:"member"` + } `json:"accept"` + }{ + Invite: struct { + Kind string `json:"kind"` + Invitation struct { + UserID string `json:"userId"` + Role string `json:"role"` + InvitedAt string `json:"invitedAt"` + InvitedBy string `json:"invitedBy"` + } `json:"invitation"` + }{ + Kind: invitePayload.Kind, + Invitation: struct { + UserID string `json:"userId"` + Role string `json:"role"` + InvitedAt string `json:"invitedAt"` + InvitedBy string `json:"invitedBy"` + }{ + UserID: invitePayload.Invitation.UserID, + Role: string(invitePayload.Invitation.Role), + InvitedAt: "placeholder", + InvitedBy: invitePayload.Invitation.InvitedBy, + }, + }, + Inbox: []struct { + UserID string `json:"userId"` + Role string `json:"role"` + InvitedAt string `json:"invitedAt"` + InvitedBy string `json:"invitedBy"` + OrgID string `json:"orgId"` + OrgDisplayName string `json:"orgDisplayName"` + }{ + { + UserID: inboxPayload[0].UserID, + Role: string(inboxPayload[0].Role), + InvitedAt: "placeholder", + InvitedBy: inboxPayload[0].InvitedBy, + OrgID: inboxPayload[0].OrgID, + OrgDisplayName: inboxPayload[0].OrgDisplayName, + }, + }, + Accept: struct { + Kind string `json:"kind"` + Member struct { + UserID string `json:"userId"` + Role string `json:"role"` + AddedAt string `json:"addedAt"` + AddedBy string `json:"addedBy"` + } `json:"member"` + }{ + Kind: acceptPayload.Kind, + Member: struct { + UserID string `json:"userId"` + Role string `json:"role"` + AddedAt string `json:"addedAt"` + AddedBy string `json:"addedBy"` + }{ + UserID: acceptPayload.Member.UserID, + Role: string(acceptPayload.Member.Role), + AddedAt: "placeholder", + AddedBy: acceptPayload.Member.AddedBy, + }, + }, + }) + if err != nil { + t.Fatalf("marshal normalized invitation transport snapshot: %v", err) + } + + const want = `{ + "invite":{ + "kind":"invitation", + "invitation":{"userId":"bob","role":"viewer","invitedAt":"placeholder","invitedBy":"alice"} + }, + "inbox":[ + {"userId":"bob","role":"viewer","invitedAt":"placeholder","invitedBy":"alice","orgId":"acme","orgDisplayName":"Acme"} + ], + "accept":{ + "kind":"member", + "member":{"userId":"bob","role":"viewer","addedAt":"placeholder","addedBy":"alice"} + } + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_OnboardingQRResponseJSONSnapshot(t *testing.T) { payload := onboardingQRResponse{ Schema: onboardingSchemaVersion, diff --git a/internal/api/org_handlers.go b/internal/api/org_handlers.go index ad19d88b5..d8d39f5ff 100644 --- a/internal/api/org_handlers.go +++ b/internal/api/org_handlers.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -110,6 +111,18 @@ type inviteMemberRequest struct { Role models.OrganizationRole `json:"role"` } +type organizationAccessMutationResponse struct { + Kind string `json:"kind"` + Member *models.OrganizationMember `json:"member,omitempty"` + Invitation *models.OrganizationInvitation `json:"invitation,omitempty"` +} + +type organizationUserInvitationResponse struct { + models.OrganizationInvitation + OrgID string `json:"orgId"` + OrgDisplayName string `json:"orgDisplayName"` +} + type createShareRequest struct { TargetOrgID string `json:"targetOrgId"` ResourceType string `json:"resourceType"` @@ -463,9 +476,15 @@ func (h *OrgHandlers) HandleInviteMember(w http.ResponseWriter, r *http.Request) } now := time.Now().UTC() + memberIndex := findOrganizationMemberIndex(org.Members, req.UserID) + invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, req.UserID) // Ownership transfer: demote old owner to admin and promote target user to owner. if req.Role == models.OrgRoleOwner && req.UserID != org.OwnerUserID { + if memberIndex < 0 { + writeErrorResponse(w, http.StatusBadRequest, "owner_transfer_requires_member", "Ownership can only be transferred to an existing member", nil) + return + } for i := range org.Members { if org.Members[i].UserID == org.OwnerUserID { org.Members[i].Role = models.OrgRoleAdmin @@ -479,29 +498,45 @@ func (h *OrgHandlers) HandleInviteMember(w http.ResponseWriter, r *http.Request) org.OwnerUserID = req.UserID } - updated := false - for i := range org.Members { - if org.Members[i].UserID == req.UserID { - org.Members[i].Role = req.Role - org.Members[i].AddedBy = username - if org.Members[i].AddedAt.IsZero() { - org.Members[i].AddedAt = now - } - updated = true - break + if memberIndex >= 0 { + org.Members[memberIndex].Role = req.Role + org.Members[memberIndex].AddedBy = username + if org.Members[memberIndex].AddedAt.IsZero() { + org.Members[memberIndex].AddedAt = now } - } - if !updated { - // Enforce max_users limit only for new member additions. + if invitationIndex >= 0 { + org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...) + } + } else { if enforceUserLimitForMemberAdd(w, r.Context(), org) { return } - org.Members = append(org.Members, models.OrganizationMember{ - UserID: req.UserID, - Role: req.Role, - AddedAt: now, - AddedBy: username, + invitation := models.OrganizationInvitation{ + UserID: req.UserID, + Role: req.Role, + InvitedAt: now, + InvitedBy: username, + } + if invitationIndex >= 0 { + org.PendingInvitations[invitationIndex].Role = req.Role + org.PendingInvitations[invitationIndex].InvitedBy = username + if org.PendingInvitations[invitationIndex].InvitedAt.IsZero() { + org.PendingInvitations[invitationIndex].InvitedAt = now + } + invitation = org.PendingInvitations[invitationIndex] + } else { + org.PendingInvitations = append(org.PendingInvitations, invitation) + } + if err := h.persistence.SaveOrganization(org); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization members", nil) + return + } + + writeJSON(w, http.StatusAccepted, organizationAccessMutationResponse{ + Kind: "invitation", + Invitation: organizationInvitationPointer(org.PendingInvitations, req.UserID), }) + return } if err := h.persistence.SaveOrganization(org); err != nil { @@ -509,15 +544,10 @@ func (h *OrgHandlers) HandleInviteMember(w http.ResponseWriter, r *http.Request) return } - normalizedMembers := normalizeOrganizationMembers(org.Members) - for _, member := range normalizedMembers { - if member.UserID == req.UserID { - writeJSON(w, http.StatusOK, member) - return - } - } - - writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization members", nil) + writeJSON(w, http.StatusOK, organizationAccessMutationResponse{ + Kind: "member", + Member: organizationMemberPointer(org.Members, req.UserID), + }) } func (h *OrgHandlers) HandleRemoveMember(w http.ResponseWriter, r *http.Request) { @@ -584,6 +614,251 @@ func (h *OrgHandlers) HandleRemoveMember(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } +func (h *OrgHandlers) HandleListInvitations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !h.requireMultiTenantGate(w, r) { + return + } + + orgID := strings.TrimSpace(r.PathValue("id")) + org, err := h.loadOrganization(orgID) + if err != nil { + h.writeLoadOrgError(w, err) + return + } + + username := auth.GetUser(r.Context()) + token := getAPITokenRecordFromRequest(r) + if token != nil || strings.HasPrefix(username, "token:") { + writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) + return + } + if !org.CanUserManage(username) { + writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) + return + } + + writeJSON(w, http.StatusOK, normalizeOrganizationInvitations(org.PendingInvitations, org.Members)) +} + +func (h *OrgHandlers) HandleRevokeInvitation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !h.requireMultiTenantGate(w, r) { + return + } + + orgID := strings.TrimSpace(r.PathValue("id")) + userID := strings.TrimSpace(r.PathValue("userId")) + if userID == "" { + writeErrorResponse(w, http.StatusBadRequest, "invalid_user", "Invitation user ID is required", nil) + return + } + + org, err := h.loadOrganization(orgID) + if err != nil { + h.writeLoadOrgError(w, err) + return + } + + username := auth.GetUser(r.Context()) + token := getAPITokenRecordFromRequest(r) + if token != nil || strings.HasPrefix(username, "token:") { + writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) + return + } + if !org.CanUserManage(username) { + writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) + return + } + + index := findOrganizationInvitationIndex(org.PendingInvitations, userID) + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "invitation_not_found", "Invitation not found", nil) + return + } + + org.PendingInvitations = append(org.PendingInvitations[:index], org.PendingInvitations[index+1:]...) + if err := h.persistence.SaveOrganization(org); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization invitations", nil) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *OrgHandlers) HandleListMyInvitations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !h.requireMultiTenantGate(w, r) { + return + } + if h.persistence == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil) + return + } + + username := auth.GetUser(r.Context()) + token := getAPITokenRecordFromRequest(r) + if token != nil || strings.HasPrefix(username, "token:") { + writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) + return + } + if username == "" { + writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil) + return + } + + orgs, err := h.persistence.ListOrganizations() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list organizations", nil) + return + } + + invitations := make([]organizationUserInvitationResponse, 0) + for _, org := range orgs { + if org == nil { + continue + } + normalizeOrganization(org) + if invitation, ok := organizationInvitationForUser(org.PendingInvitations, username); ok { + invitations = append(invitations, organizationUserInvitationResponse{ + OrganizationInvitation: invitation, + OrgID: org.ID, + OrgDisplayName: org.DisplayName, + }) + } + } + sort.Slice(invitations, func(i, j int) bool { + if invitations[i].OrgID == invitations[j].OrgID { + return invitations[i].InvitedAt.Before(invitations[j].InvitedAt) + } + return invitations[i].OrgID < invitations[j].OrgID + }) + + writeJSON(w, http.StatusOK, invitations) +} + +func (h *OrgHandlers) HandleAcceptMyInvitation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !h.requireMultiTenantGate(w, r) { + return + } + + orgID := strings.TrimSpace(r.PathValue("id")) + org, err := h.loadOrganization(orgID) + if err != nil { + h.writeLoadOrgError(w, err) + return + } + + username := auth.GetUser(r.Context()) + token := getAPITokenRecordFromRequest(r) + if token != nil || strings.HasPrefix(username, "token:") { + writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) + return + } + if username == "" { + writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil) + return + } + + if memberIndex := findOrganizationMemberIndex(org.Members, username); memberIndex >= 0 { + if invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, username); invitationIndex >= 0 { + org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...) + if err := h.persistence.SaveOrganization(org); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "invite_accept_failed", "Failed to update organization membership", nil) + return + } + } + writeJSON(w, http.StatusOK, organizationAccessMutationResponse{ + Kind: "member", + Member: organizationMemberPointer(org.Members, username), + }) + return + } + + invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, username) + if invitationIndex < 0 { + writeErrorResponse(w, http.StatusNotFound, "invitation_not_found", "Invitation not found", nil) + return + } + if enforceUserLimitForMemberAdd(w, r.Context(), org) { + return + } + + invitation := org.PendingInvitations[invitationIndex] + now := time.Now().UTC() + org.Members = append(org.Members, models.OrganizationMember{ + UserID: username, + Role: invitation.Role, + AddedAt: now, + AddedBy: invitation.InvitedBy, + }) + org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...) + if err := h.persistence.SaveOrganization(org); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "invite_accept_failed", "Failed to update organization membership", nil) + return + } + + writeJSON(w, http.StatusOK, organizationAccessMutationResponse{ + Kind: "member", + Member: organizationMemberPointer(org.Members, username), + }) +} + +func (h *OrgHandlers) HandleDeclineMyInvitation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !h.requireMultiTenantGate(w, r) { + return + } + + orgID := strings.TrimSpace(r.PathValue("id")) + org, err := h.loadOrganization(orgID) + if err != nil { + h.writeLoadOrgError(w, err) + return + } + + username := auth.GetUser(r.Context()) + token := getAPITokenRecordFromRequest(r) + if token != nil || strings.HasPrefix(username, "token:") { + writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) + return + } + if username == "" { + writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil) + return + } + + invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, username) + if invitationIndex < 0 { + writeErrorResponse(w, http.StatusNotFound, "invitation_not_found", "Invitation not found", nil) + return + } + + org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...) + if err := h.persistence.SaveOrganization(org); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "invite_decline_failed", "Failed to update organization invitations", nil) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (h *OrgHandlers) HandleListShares(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -925,22 +1200,29 @@ func normalizeOrganization(org *models.Organization) { return } org.Members = normalizeOrganizationMembers(org.Members) - org.SharedResources = normalizeOrganizationShares(org.SharedResources) if strings.TrimSpace(org.OwnerUserID) == "" { + org.PendingInvitations = normalizeOrganizationInvitations(org.PendingInvitations, org.Members) + org.SharedResources = normalizeOrganizationShares(org.SharedResources) return } + foundOwner := false for i := range org.Members { if org.Members[i].UserID == org.OwnerUserID { org.Members[i].Role = models.OrgRoleOwner - return + foundOwner = true + break } } - org.Members = append(org.Members, models.OrganizationMember{ - UserID: org.OwnerUserID, - Role: models.OrgRoleOwner, - AddedAt: time.Now().UTC(), - AddedBy: org.OwnerUserID, - }) + if !foundOwner { + org.Members = append(org.Members, models.OrganizationMember{ + UserID: org.OwnerUserID, + Role: models.OrgRoleOwner, + AddedAt: time.Now().UTC(), + AddedBy: org.OwnerUserID, + }) + } + org.PendingInvitations = normalizeOrganizationInvitations(org.PendingInvitations, org.Members) + org.SharedResources = normalizeOrganizationShares(org.SharedResources) } func normalizeOrganizationMembers(members []models.OrganizationMember) []models.OrganizationMember { @@ -959,6 +1241,34 @@ func normalizeOrganizationMembers(members []models.OrganizationMember) []models. return normalized } +func normalizeOrganizationInvitations(invites []models.OrganizationInvitation, members []models.OrganizationMember) []models.OrganizationInvitation { + if len(invites) == 0 { + return nil + } + memberSet := make(map[string]struct{}, len(members)) + for _, member := range members { + memberSet[strings.TrimSpace(member.UserID)] = struct{}{} + } + + normalized := make([]models.OrganizationInvitation, 0, len(invites)) + for _, invite := range invites { + invite.UserID = strings.TrimSpace(invite.UserID) + if invite.UserID == "" { + continue + } + if _, exists := memberSet[invite.UserID]; exists { + continue + } + invite.Role = models.NormalizeOrganizationRole(invite.Role) + if !models.IsValidOrganizationRole(invite.Role) || invite.Role == models.OrgRoleOwner { + continue + } + invite.InvitedBy = strings.TrimSpace(invite.InvitedBy) + normalized = append(normalized, invite) + } + return normalized +} + func normalizeOrganizationShares(shares []models.OrganizationShare) []models.OrganizationShare { normalized := make([]models.OrganizationShare, 0, len(shares)) for _, share := range shares { @@ -986,6 +1296,50 @@ func normalizeOrganizationShares(shares []models.OrganizationShare) []models.Org return normalized } +func findOrganizationMemberIndex(members []models.OrganizationMember, userID string) int { + for i := range members { + if members[i].UserID == userID { + return i + } + } + return -1 +} + +func findOrganizationInvitationIndex(invitations []models.OrganizationInvitation, userID string) int { + for i := range invitations { + if invitations[i].UserID == userID { + return i + } + } + return -1 +} + +func organizationMemberPointer(members []models.OrganizationMember, userID string) *models.OrganizationMember { + memberIndex := findOrganizationMemberIndex(members, userID) + if memberIndex < 0 { + return nil + } + member := members[memberIndex] + return &member +} + +func organizationInvitationPointer(invitations []models.OrganizationInvitation, userID string) *models.OrganizationInvitation { + invitationIndex := findOrganizationInvitationIndex(invitations, userID) + if invitationIndex < 0 { + return nil + } + invitation := invitations[invitationIndex] + return &invitation +} + +func organizationInvitationForUser(invitations []models.OrganizationInvitation, userID string) (models.OrganizationInvitation, bool) { + invitationIndex := findOrganizationInvitationIndex(invitations, userID) + if invitationIndex < 0 { + return models.OrganizationInvitation{}, false + } + return invitations[invitationIndex], true +} + func generateOrganizationShareID() string { return "shr-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36) } diff --git a/internal/api/org_handlers_test.go b/internal/api/org_handlers_test.go index 9f98c13b6..0dbca5f79 100644 --- a/internal/api/org_handlers_test.go +++ b/internal/api/org_handlers_test.go @@ -223,10 +223,12 @@ func TestOrgHandlersCRUDLifecycle(t *testing.T) { inviteReq.SetPathValue("id", "acme") inviteRec := httptest.NewRecorder() h.HandleInviteMember(inviteRec, inviteReq) - if inviteRec.Code != http.StatusOK { - t.Fatalf("expected 200 from invite, got %d: %s", inviteRec.Code, inviteRec.Body.String()) + if inviteRec.Code != http.StatusAccepted { + t.Fatalf("expected 202 from invite, got %d: %s", inviteRec.Code, inviteRec.Body.String()) } + acceptInvitationForTest(t, h, "acme", "bob") + membersReq := withUser(httptest.NewRequest(http.MethodGet, "/api/orgs/acme/members", nil), "bob") membersReq.SetPathValue("id", "acme") membersRec := httptest.NewRecorder() @@ -284,10 +286,12 @@ func TestOrgHandlersViewerCannotManageOrg(t *testing.T) { inviteReq.SetPathValue("id", "acme") inviteRec := httptest.NewRecorder() h.HandleInviteMember(inviteRec, inviteReq) - if inviteRec.Code != http.StatusOK { + if inviteRec.Code != http.StatusAccepted { t.Fatalf("invite failed: %d %s", inviteRec.Code, inviteRec.Body.String()) } + acceptInvitationForTest(t, h, "acme", "bob") + updateReq := withUser( httptest.NewRequest(http.MethodPut, "/api/orgs/acme", bytes.NewBufferString(`{"displayName":"Nope"}`)), "bob", @@ -414,6 +418,9 @@ func TestOrgHandlersOwnershipTransfer(t *testing.T) { t.Fatalf("create failed: %d %s", createRec.Code, createRec.Body.String()) } + inviteMemberForTest(t, h, "acme", "alice", "bob", "viewer", http.StatusAccepted) + acceptInvitationForTest(t, h, "acme", "bob") + transferReq := withUser( httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", bytes.NewBufferString(`{"userId":"bob","role":"owner"}`)), "alice", @@ -463,16 +470,8 @@ func TestOrgHandlersRemoveMember(t *testing.T) { t.Fatalf("create failed: %d %s", createRec.Code, createRec.Body.String()) } - inviteReq := withUser( - httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", bytes.NewBufferString(`{"userId":"bob","role":"editor"}`)), - "alice", - ) - inviteReq.SetPathValue("id", "acme") - inviteRec := httptest.NewRecorder() - h.HandleInviteMember(inviteRec, inviteReq) - if inviteRec.Code != http.StatusOK { - t.Fatalf("invite failed: %d %s", inviteRec.Code, inviteRec.Body.String()) - } + inviteMemberForTest(t, h, "acme", "alice", "bob", "editor", http.StatusAccepted) + acceptInvitationForTest(t, h, "acme", "bob") removeReq := withUser(httptest.NewRequest(http.MethodDelete, "/api/orgs/acme/members/bob", nil), "alice") removeReq.SetPathValue("id", "acme") @@ -499,6 +498,108 @@ func TestOrgHandlersRemoveMember(t *testing.T) { } } +func TestOrgHandlersInvitationsRequireAcceptance(t *testing.T) { + t.Setenv("PULSE_DEV", "true") + defer SetMultiTenantEnabled(false) + SetMultiTenantEnabled(true) + + persistence := config.NewMultiTenantPersistence(t.TempDir()) + h := NewOrgHandlers(persistence, nil) + + createReq := withUser( + httptest.NewRequest(http.MethodPost, "/api/orgs", bytes.NewBufferString(`{"id":"acme","displayName":"Acme"}`)), + "alice", + ) + createRec := httptest.NewRecorder() + h.HandleCreateOrg(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create failed: %d %s", createRec.Code, createRec.Body.String()) + } + + inviteRec := inviteMemberForTest(t, h, "acme", "alice", "bob", "admin", http.StatusAccepted) + var invitePayload organizationAccessMutationResponse + if err := json.Unmarshal(inviteRec.Body.Bytes(), &invitePayload); err != nil { + t.Fatalf("decode invite payload: %v", err) + } + if invitePayload.Kind != "invitation" || invitePayload.Invitation == nil { + t.Fatalf("expected invitation payload, got %+v", invitePayload) + } + + bobGetReq := withUser(httptest.NewRequest(http.MethodGet, "/api/orgs/acme", nil), "bob") + bobGetReq.SetPathValue("id", "acme") + bobGetRec := httptest.NewRecorder() + h.HandleGetOrg(bobGetRec, bobGetReq) + if bobGetRec.Code != http.StatusForbidden { + t.Fatalf("expected bob to be blocked before accepting invite, got %d: %s", bobGetRec.Code, bobGetRec.Body.String()) + } + + myInvitesReq := withUser(httptest.NewRequest(http.MethodGet, "/api/org-invitations", nil), "bob") + myInvitesRec := httptest.NewRecorder() + h.HandleListMyInvitations(myInvitesRec, myInvitesReq) + if myInvitesRec.Code != http.StatusOK { + t.Fatalf("expected 200 from my invitations, got %d: %s", myInvitesRec.Code, myInvitesRec.Body.String()) + } + var myInvites []organizationUserInvitationResponse + if err := json.Unmarshal(myInvitesRec.Body.Bytes(), &myInvites); err != nil { + t.Fatalf("decode my invitations: %v", err) + } + if len(myInvites) != 1 || myInvites[0].OrgID != "acme" { + t.Fatalf("unexpected invitation inbox: %+v", myInvites) + } + + managerInvitesReq := withUser(httptest.NewRequest(http.MethodGet, "/api/orgs/acme/invitations", nil), "alice") + managerInvitesReq.SetPathValue("id", "acme") + managerInvitesRec := httptest.NewRecorder() + h.HandleListInvitations(managerInvitesRec, managerInvitesReq) + if managerInvitesRec.Code != http.StatusOK { + t.Fatalf("expected 200 from invitation list, got %d: %s", managerInvitesRec.Code, managerInvitesRec.Body.String()) + } + var pending []models.OrganizationInvitation + if err := json.Unmarshal(managerInvitesRec.Body.Bytes(), &pending); err != nil { + t.Fatalf("decode pending invitations: %v", err) + } + if len(pending) != 1 || pending[0].UserID != "bob" { + t.Fatalf("unexpected pending invitations: %+v", pending) + } + + acceptInvitationForTest(t, h, "acme", "bob") + + orgAfterAccept, err := persistence.LoadOrganization("acme") + if err != nil { + t.Fatalf("load org after accept: %v", err) + } + if orgAfterAccept.GetMemberRole("bob") != models.OrgRoleAdmin { + t.Fatalf("expected bob to become admin after acceptance, got %q", orgAfterAccept.GetMemberRole("bob")) + } + if len(orgAfterAccept.PendingInvitations) != 0 { + t.Fatalf("expected invitations to be cleared after acceptance, got %+v", orgAfterAccept.PendingInvitations) + } +} + +func TestOrgHandlersRejectOwnershipTransferToNonMember(t *testing.T) { + t.Setenv("PULSE_DEV", "true") + defer SetMultiTenantEnabled(false) + SetMultiTenantEnabled(true) + + persistence := config.NewMultiTenantPersistence(t.TempDir()) + h := NewOrgHandlers(persistence, nil) + + createReq := withUser( + httptest.NewRequest(http.MethodPost, "/api/orgs", bytes.NewBufferString(`{"id":"acme","displayName":"Acme"}`)), + "alice", + ) + createRec := httptest.NewRecorder() + h.HandleCreateOrg(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create failed: %d %s", createRec.Code, createRec.Body.String()) + } + + transferRec := inviteMemberForTest(t, h, "acme", "alice", "bob", "owner", http.StatusBadRequest) + if !strings.Contains(transferRec.Body.String(), "existing member") { + t.Fatalf("expected owner transfer rejection to explain member prerequisite, got %s", transferRec.Body.String()) + } +} + func TestOrgHandlersShareLifecycle(t *testing.T) { t.Setenv("PULSE_DEV", "true") defer SetMultiTenantEnabled(false) @@ -1027,3 +1128,30 @@ func TestHandleCreateShareRejectsUnsupportedCustomResourceType(t *testing.T) { func withUser(req *http.Request, username string) *http.Request { return req.WithContext(internalauth.WithUser(req.Context(), username)) } + +func inviteMemberForTest(t *testing.T, h *OrgHandlers, orgID, actor, userID, role string, wantStatus int) *httptest.ResponseRecorder { + t.Helper() + req := withUser( + httptest.NewRequest(http.MethodPost, "/api/orgs/"+orgID+"/members", bytes.NewBufferString(`{"userId":"`+userID+`","role":"`+role+`"}`)), + actor, + ) + req.SetPathValue("id", orgID) + rec := httptest.NewRecorder() + h.HandleInviteMember(rec, req) + if rec.Code != wantStatus { + t.Fatalf("invite %s as %s returned %d, want %d: %s", userID, role, rec.Code, wantStatus, rec.Body.String()) + } + return rec +} + +func acceptInvitationForTest(t *testing.T, h *OrgHandlers, orgID, username string) *httptest.ResponseRecorder { + t.Helper() + req := withUser(httptest.NewRequest(http.MethodPost, "/api/org-invitations/"+orgID+"/accept", nil), username) + req.SetPathValue("id", orgID) + rec := httptest.NewRecorder() + h.HandleAcceptMyInvitation(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("accept invitation for %s returned %d: %s", username, rec.Code, rec.Body.String()) + } + return rec +} diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index c3df55e9e..ce1443067 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -477,6 +477,11 @@ var allRouteAllowlist = []string{ "GET /api/orgs/{id}/members", "POST /api/orgs/{id}/members", "DELETE /api/orgs/{id}/members/{userId}", + "GET /api/orgs/{id}/invitations", + "DELETE /api/orgs/{id}/invitations/{userId}", + "GET /api/org-invitations", + "POST /api/org-invitations/{id}/accept", + "DELETE /api/org-invitations/{id}", "GET /api/orgs/{id}/shares", "GET /api/orgs/{id}/shares/incoming", "POST /api/orgs/{id}/shares", diff --git a/internal/api/router_routes_licensing.go b/internal/api/router_routes_licensing.go index 49b2485ad..f73741f00 100644 --- a/internal/api/router_routes_licensing.go +++ b/internal/api/router_routes_licensing.go @@ -73,6 +73,11 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan r.mux.HandleFunc("GET /api/orgs/{id}/members", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, orgHandlers.HandleListMembers))) r.mux.HandleFunc("POST /api/orgs/{id}/members", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, orgHandlers.HandleInviteMember))) r.mux.HandleFunc("DELETE /api/orgs/{id}/members/{userId}", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, orgHandlers.HandleRemoveMember))) + r.mux.HandleFunc("GET /api/orgs/{id}/invitations", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, orgHandlers.HandleListInvitations))) + r.mux.HandleFunc("DELETE /api/orgs/{id}/invitations/{userId}", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, orgHandlers.HandleRevokeInvitation))) + r.mux.HandleFunc("GET /api/org-invitations", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, orgHandlers.HandleListMyInvitations))) + r.mux.HandleFunc("POST /api/org-invitations/{id}/accept", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, orgHandlers.HandleAcceptMyInvitation))) + r.mux.HandleFunc("DELETE /api/org-invitations/{id}", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, orgHandlers.HandleDeclineMyInvitation))) r.mux.HandleFunc("GET /api/orgs/{id}/shares", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, orgHandlers.HandleListShares))) r.mux.HandleFunc("GET /api/orgs/{id}/shares/incoming", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, orgHandlers.HandleListIncomingShares))) r.mux.HandleFunc("POST /api/orgs/{id}/shares", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, orgHandlers.HandleCreateShare))) diff --git a/internal/api/user_limit_isolation_test.go b/internal/api/user_limit_isolation_test.go index 184354b47..e3b636b29 100644 --- a/internal/api/user_limit_isolation_test.go +++ b/internal/api/user_limit_isolation_test.go @@ -78,27 +78,20 @@ func TestUserLimitIsolation_ExistingMemberUpdateNotBlocked(t *testing.T) { persistence := config.NewMultiTenantPersistence(t.TempDir()) h := NewOrgHandlers(persistence, nil) - createReq := withUser( - httptest.NewRequest(http.MethodPost, "/api/orgs", bytes.NewBufferString(`{"id":"acme","displayName":"Acme"}`)), - "alice", - ) - createRec := httptest.NewRecorder() - h.HandleCreateOrg(createRec, createReq) - if createRec.Code != http.StatusCreated { - t.Fatalf("create failed: %d %s", createRec.Code, createRec.Body.String()) + seedOrg := &models.Organization{ + ID: "acme", + DisplayName: "Acme", + OwnerUserID: "alice", + Members: []models.OrganizationMember{ + {UserID: "alice", Role: models.OrgRoleOwner}, + {UserID: "bob", Role: models.OrgRoleViewer}, + {UserID: "charlie", Role: models.OrgRoleViewer}, + {UserID: "dave", Role: models.OrgRoleViewer}, + {UserID: "erin", Role: models.OrgRoleViewer}, + }, } - - for _, userID := range []string{"bob", "charlie", "dave", "erin"} { - inviteReq := withUser( - httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", bytes.NewBufferString(`{"userId":"`+userID+`","role":"viewer"}`)), - "alice", - ) - inviteReq.SetPathValue("id", "acme") - inviteRec := httptest.NewRecorder() - h.HandleInviteMember(inviteRec, inviteReq) - if inviteRec.Code != http.StatusOK { - t.Fatalf("invite %s failed: %d %s", userID, inviteRec.Code, inviteRec.Body.String()) - } + if err := persistence.SaveOrganization(seedOrg); err != nil { + t.Fatalf("seed org failed: %v", err) } orgBeforeUpdate, err := persistence.LoadOrganization("acme") diff --git a/internal/models/organization.go b/internal/models/organization.go index 253191229..6f4c77169 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -109,6 +109,22 @@ type OrganizationMember struct { AddedBy string `json:"addedBy,omitempty"` } +// OrganizationInvitation represents pending organization access awaiting +// explicit acceptance by the invited user. +type OrganizationInvitation struct { + // UserID is the invited username. + UserID string `json:"userId"` + + // Role is the role that will be granted on acceptance. + Role OrganizationRole `json:"role"` + + // InvitedAt is when the invitation was created. + InvitedAt time.Time `json:"invitedAt"` + + // InvitedBy is the user ID that created the invitation. + InvitedBy string `json:"invitedBy"` +} + // OrganizationShare represents a cross-organization resource/view share. type OrganizationShare struct { // ID is a unique identifier for the share record. @@ -160,6 +176,10 @@ type Organization struct { // This includes the owner (with OrgRoleOwner) and any additional members. Members []OrganizationMember `json:"members,omitempty"` + // PendingInvitations contains invited users who must explicitly accept + // organization access before membership becomes active. + PendingInvitations []OrganizationInvitation `json:"pendingInvitations,omitempty"` + // SharedResources contains outgoing cross-organization shares. SharedResources []OrganizationShare `json:"sharedResources,omitempty"` diff --git a/pkg/licensing/entitlement_payload.go b/pkg/licensing/entitlement_payload.go index 2e36ee19e..af2dce207 100644 --- a/pkg/licensing/entitlement_payload.go +++ b/pkg/licensing/entitlement_payload.go @@ -253,6 +253,20 @@ type LegacyConnectionCounts struct { KubernetesClusters int64 `json:"kubernetes_clusters"` } +func cloneCapabilityKeys(values []string) []string { + if len(values) == 0 { + return []string{} + } + return append([]string(nil), values...) +} + +func cloneLimitStatuses(values []LimitStatus) []LimitStatus { + if len(values) == 0 { + return []LimitStatus{} + } + return append([]LimitStatus(nil), values...) +} + func (c LegacyConnectionCounts) Total() int64 { return c.ProxmoxNodes + c.DockerHosts + c.KubernetesClusters } @@ -324,8 +338,8 @@ func BuildRuntimeCapabilitiesPayloadWithUsage( ) RuntimeCapabilitiesPayload { entitlementPayload := BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, nil) return RuntimeCapabilitiesPayload{ - Capabilities: append([]string(nil), entitlementPayload.Capabilities...), - Limits: append([]LimitStatus(nil), entitlementPayload.Limits...), + Capabilities: cloneCapabilityKeys(entitlementPayload.Capabilities), + Limits: cloneLimitStatuses(entitlementPayload.Limits), HostedMode: entitlementPayload.HostedMode, MaxHistoryDays: entitlementPayload.MaxHistoryDays, MonitoredSystemCapacity: cloneMonitoredSystemCapacityStatus( diff --git a/pkg/licensing/entitlement_payload_test.go b/pkg/licensing/entitlement_payload_test.go index 948ecc6a3..5882e11af 100644 --- a/pkg/licensing/entitlement_payload_test.go +++ b/pkg/licensing/entitlement_payload_test.go @@ -627,6 +627,32 @@ func TestBuildRuntimeCapabilitiesPayload_NilStatusDefaultsToFreeTier(t *testing. } } +func TestBuildRuntimeCapabilitiesPayload_EmptyCollectionsRemainJSONArrays(t *testing.T) { + payload := BuildRuntimeCapabilitiesPayloadWithUsage(&LicenseStatus{ + Valid: true, + Tier: TierFree, + Features: []string{}, + }, "", EntitlementUsageSnapshot{}) + + if payload.Capabilities == nil { + t.Fatal("expected runtime capabilities to preserve empty capabilities as []") + } + if payload.Limits == nil { + t.Fatal("expected runtime capabilities to preserve empty limits as []") + } + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal runtime capabilities: %v", err) + } + if strings.Contains(string(body), `"capabilities":null`) { + t.Fatalf("runtime capabilities encoded null capabilities: %s", string(body)) + } + if strings.Contains(string(body), `"limits":null`) { + t.Fatalf("runtime capabilities encoded null limits: %s", string(body)) + } +} + func TestLimitState(t *testing.T) { tests := []struct { name string diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index fadb19228..fb94f3d14 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3602,14 +3602,14 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "frontend-modern/src/api/orgs.ts", - "line": 61, - "heading_line": 59, + "line": 62, + "heading_line": 60, }, { "heading": "## Extension Points", "path": "frontend-modern/src/api/orgs.ts", - "line": 72, - "heading_line": 68, + "line": 73, + "heading_line": 69, }, ], ) @@ -3672,20 +3672,20 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Canonical Files", "path": "internal/api/access_control_handlers.go", - "line": 53, + "line": 54, "heading_line": 22, }, { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 63, - "heading_line": 59, + "line": 64, + "heading_line": 60, }, { "heading": "## Extension Points", "path": "internal/api/access_control_handlers.go", - "line": 74, - "heading_line": 68, + "line": 75, + "heading_line": 69, }, ], ) diff --git a/tests/integration/tests/03-multi-tenant.spec.ts b/tests/integration/tests/03-multi-tenant.spec.ts index ff2577f2b..7ed0d2839 100644 --- a/tests/integration/tests/03-multi-tenant.spec.ts +++ b/tests/integration/tests/03-multi-tenant.spec.ts @@ -18,6 +18,11 @@ type OrganizationMember = { role: string; }; +type OrganizationInvitation = { + userId: string; + role: string; +}; + type APITokenCreateResponse = { token?: string; }; @@ -114,19 +119,22 @@ test.describe('Multi-tenant E2E flows', () => { data: { userId: 'testuser', role: 'viewer' }, headers: { 'Content-Type': 'application/json' }, }); - expect(addMemberRes.ok()).toBeTruthy(); + expect(addMemberRes.status()).toBe(202); - const membersRes = await apiRequest(page, `/api/orgs/${encodeURIComponent(orgID)}/members`); - expect(membersRes.ok()).toBeTruthy(); - const members = (await membersRes.json()) as OrganizationMember[]; - expect(members.some((member) => member.userId === 'testuser')).toBeTruthy(); - - const removeMemberRes = await apiRequest( + const invitationsRes = await apiRequest( page, - `/api/orgs/${encodeURIComponent(orgID)}/members/testuser`, + `/api/orgs/${encodeURIComponent(orgID)}/invitations`, + ); + expect(invitationsRes.ok()).toBeTruthy(); + const invitations = (await invitationsRes.json()) as OrganizationInvitation[]; + expect(invitations.some((invitation) => invitation.userId === 'testuser')).toBeTruthy(); + + const revokeInvitationRes = await apiRequest( + page, + `/api/orgs/${encodeURIComponent(orgID)}/invitations/testuser`, { method: 'DELETE' }, ); - expect(removeMemberRes.ok()).toBeTruthy(); + expect(revokeInvitationRes.ok()).toBeTruthy(); await deleteOrg(page, orgID); diff --git a/tests/integration/tests/15-settings-shell-consistency.spec.ts b/tests/integration/tests/15-settings-shell-consistency.spec.ts index d2dbb710a..2c0f79dbe 100644 --- a/tests/integration/tests/15-settings-shell-consistency.spec.ts +++ b/tests/integration/tests/15-settings-shell-consistency.spec.ts @@ -21,6 +21,11 @@ const SETTINGS_SHELL_ROUTES = [ title: 'Organization Overview', description: 'Review organization metadata, membership footprint, and ownership.', }, + { + route: '/settings/organization/access', + title: 'Organization Access', + description: 'Manage organization invitations, member roles, and ownership transfers.', + }, { route: '/settings/organization/billing', title: 'Organization Billing',