Require accepted org invitations and stable runtime capabilities

This commit is contained in:
rcourtman 2026-04-22 03:06:22 +01:00
parent 7be844f23a
commit f7c1d9b629
36 changed files with 1620 additions and 149 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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';",
);

View file

@ -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',

View file

@ -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);

View file

@ -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> {

View file

@ -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>(

View file

@ -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>
);

View file

@ -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>

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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';",

View file

@ -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');
});
});
});

View file

@ -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',

View file

@ -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,
};
}

View file

@ -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);
}
});

View file

@ -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

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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)
}

View file

@ -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
}

View file

@ -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",

View file

@ -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)))

View file

@ -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")

View file

@ -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"`

View file

@ -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(

View file

@ -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

View file

@ -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,
},
],
)

View file

@ -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);

View file

@ -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',