mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-21 18:46:08 +00:00
Require accepted org invitations and stable runtime capabilities
This commit is contained in:
parent
7be844f23a
commit
f7c1d9b629
36 changed files with 1620 additions and 149 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,28 @@ export interface LicenseFeatureStatus {
|
|||
upgrade_url: string;
|
||||
}
|
||||
|
||||
function normalizeRuntimeCapabilities(
|
||||
payload: unknown,
|
||||
): LicenseRuntimeCapabilities {
|
||||
const source =
|
||||
payload && typeof payload === 'object'
|
||||
? (payload as Partial<LicenseRuntimeCapabilities>)
|
||||
: {};
|
||||
|
||||
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<LicenseRuntimeCapabilities> {
|
||||
return apiFetchJSON(
|
||||
`${this.baseUrl}/runtime-capabilities`,
|
||||
) as Promise<LicenseRuntimeCapabilities>;
|
||||
const payload = await apiFetchJSON(`${this.baseUrl}/runtime-capabilities`);
|
||||
return normalizeRuntimeCapabilities(payload);
|
||||
}
|
||||
|
||||
static async getCommercialPosture(): Promise<LicenseCommercialPosture> {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ export interface OrganizationMember {
|
|||
addedBy?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationInvitation {
|
||||
userId: string;
|
||||
role: Exclude<OrganizationRole, 'owner'>;
|
||||
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<OrganizationInvitation[]>(
|
||||
`/api/orgs/${encodeURIComponent(id)}/invitations`,
|
||||
{
|
||||
skipOrgContext: true,
|
||||
},
|
||||
),
|
||||
|
||||
listMyInvitations: () =>
|
||||
apiFetchJSON<UserOrganizationInvitation[]>('/api/org-invitations', {
|
||||
skipOrgContext: true,
|
||||
}),
|
||||
|
||||
inviteMember: (id: string, payload: { userId: string; role: OrganizationRole }) =>
|
||||
apiFetchJSON<OrganizationMember>(`/api/orgs/${encodeURIComponent(id)}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
apiFetchJSON<OrganizationAccessMutationResult>(
|
||||
`/api/orgs/${encodeURIComponent(id)}/members`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
skipOrgContext: true,
|
||||
},
|
||||
),
|
||||
|
||||
acceptMyInvitation: (id: string) =>
|
||||
apiFetchJSON<OrganizationAccessMutationResult>(
|
||||
`/api/org-invitations/${encodeURIComponent(id)}/accept`,
|
||||
{
|
||||
method: 'POST',
|
||||
skipOrgContext: true,
|
||||
},
|
||||
),
|
||||
|
||||
declineMyInvitation: (id: string) =>
|
||||
apiFetchJSON<void>(`/api/org-invitations/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
skipOrgContext: true,
|
||||
}),
|
||||
|
||||
updateMemberRole: (id: string, payload: { userId: string; role: OrganizationRole }) =>
|
||||
apiFetchJSON<OrganizationMember>(`/api/orgs/${encodeURIComponent(id)}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
skipOrgContext: true,
|
||||
}),
|
||||
apiFetchJSON<OrganizationAccessMutationResult>(
|
||||
`/api/orgs/${encodeURIComponent(id)}/members`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
skipOrgContext: true,
|
||||
},
|
||||
),
|
||||
|
||||
revokeInvitation: (id: string, userId: string) =>
|
||||
apiFetchJSON<void>(
|
||||
`/api/orgs/${encodeURIComponent(id)}/invitations/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
skipOrgContext: true,
|
||||
},
|
||||
),
|
||||
|
||||
removeMember: (id: string, userId: string) =>
|
||||
apiFetchJSON<void>(
|
||||
|
|
|
|||
|
|
@ -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<typeof useOrganizationAccessPanelState>;
|
||||
}
|
||||
|
||||
export const OrganizationAccessInvitationsSection: Component<
|
||||
OrganizationAccessInvitationsSectionProps
|
||||
> = (props) => (
|
||||
<div class="space-y-4">
|
||||
<Show when={props.state.myInvitations().length > 0}>
|
||||
<section class="rounded-md border border-border p-4 space-y-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-base-content">Your Invitations</h4>
|
||||
<p class="text-sm text-muted">Accept or decline pending organization access.</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={props.state.myInvitations()}>
|
||||
{(invitation) => (
|
||||
<div class="rounded-md border border-border bg-surface px-3 py-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-base-content">
|
||||
{invitation.orgDisplayName || invitation.orgId}
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
Invited by {invitation.invitedBy || 'an admin'} on{' '}
|
||||
{formatOrgDate(invitation.invitedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(invitation.role)}`}
|
||||
>
|
||||
{invitation.role}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.state.acceptInvitation(invitation.orgId)}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.state.declineInvitation(invitation.orgId)}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:border-red-300 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={props.state.canManageCurrentOrg() && props.state.pendingInvitations().length > 0}>
|
||||
<section class="rounded-md border border-border p-4 space-y-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-base-content">Pending Invitations</h4>
|
||||
<p class="text-sm text-muted">These users still need to accept access.</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={props.state.pendingInvitations()}>
|
||||
{(invitation) => (
|
||||
<div class="flex flex-col gap-2 rounded-md border border-border bg-surface px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-base-content">{invitation.userId}</div>
|
||||
<div class="text-xs text-muted">
|
||||
Invited by {invitation.invitedBy || 'an admin'} on{' '}
|
||||
{formatOrgDate(invitation.invitedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(invitation.role)}`}
|
||||
>
|
||||
{invitation.role}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.state.revokeInvitation(invitation.userId)}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:border-red-300 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -17,7 +17,7 @@ export const OrganizationAccessManagementSection: Component<
|
|||
<>
|
||||
<Show when={props.state.canManageCurrentOrg()}>
|
||||
<div class="rounded-md border border-border p-4 space-y-3">
|
||||
<h4 class="text-sm font-semibold text-base-content">Add Member</h4>
|
||||
<h4 class="text-sm font-semibold text-base-content">Invite Member</h4>
|
||||
<div class="grid gap-2 sm:grid-cols-[1fr_auto_auto]">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -48,7 +48,7 @@ export const OrganizationAccessManagementSection: Component<
|
|||
disabled={props.state.saving()}
|
||||
class="inline-flex w-full sm:w-auto items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{props.state.saving() ? 'Saving...' : 'Add'}
|
||||
{props.state.saving() ? 'Saving...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<OrganizationAccessPanelProps> =
|
|||
<div class="space-y-6">
|
||||
<SettingsPanel
|
||||
title="Organization Access"
|
||||
description="Manage organization member roles and ownership transfers."
|
||||
description="Manage organization invitations, member roles, and ownership transfers."
|
||||
icon={<Users class="w-5 h-5" />}
|
||||
bodyClass="space-y-5"
|
||||
>
|
||||
<Show when={!state.loading()} fallback={<OrganizationAccessLoadingState />}>
|
||||
<Show when={state.org()}>
|
||||
<OrganizationAccessInvitationsSection state={state} />
|
||||
<OrganizationAccessManagementSection state={state} currentUser={props.currentUser} />
|
||||
<OrganizationAccessMembersSection state={state} currentUser={props.currentUser} />
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -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 = <T,>() => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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';",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Organization | null>(null);
|
||||
const [members, setMembers] = createSignal<OrganizationMember[]>([]);
|
||||
const [pendingInvitations, setPendingInvitations] = createSignal<OrganizationInvitation[]>([]);
|
||||
const [myInvitations, setMyInvitations] = createSignal<UserOrganizationInvitation[]>([]);
|
||||
const [inviteUserID, setInviteUserID] = createSignal('');
|
||||
const [inviteRole, setInviteRole] = createSignal<OrganizationRole>('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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OrganizationRole, 'owner'>,
|
||||
): 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue