Skip to content

Commit fc85d55

Browse files
feat(mosaic): wire up organization profile danger zone (#10069)
1 parent 2a7d261 commit fc85d55

35 files changed

Lines changed: 603 additions & 243 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/__tests__/feature/fake-fapi.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,34 @@ function updateUser(state: FakeFapiState, user: UserJSON): void {
109109
};
110110
}
111111

112+
function withoutOrganization(session: SessionJSON, organizationId: string): SessionJSON {
113+
return {
114+
...session,
115+
user: {
116+
...session.user,
117+
organization_memberships: session.user.organization_memberships.filter(m => m.organization.id !== organizationId),
118+
},
119+
};
120+
}
121+
122+
function leaveOrganization(state: FakeFapiState, organizationId: string, userId: string): void {
123+
state.memberships = state.memberships.filter(m => m.organization.id !== organizationId);
124+
state.client = {
125+
...state.client,
126+
sessions: state.client.sessions.map(session =>
127+
session.user.id === userId ? withoutOrganization(session, organizationId) : session,
128+
),
129+
};
130+
}
131+
132+
function deleteOrganization(state: FakeFapiState, organizationId: string): void {
133+
state.memberships = state.memberships.filter(m => m.organization.id !== organizationId);
134+
state.client = {
135+
...state.client,
136+
sessions: state.client.sessions.map(session => withoutOrganization(session, organizationId)),
137+
};
138+
}
139+
112140
function missing() {
113141
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
114142
}
@@ -279,6 +307,29 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
279307
const url = new URL(request.url);
280308
return envelope(page(withStatus(state.suggestions, url), url), null);
281309
}),
310+
http.post(fapiUrl('/v1/me/organization_memberships/:organizationId'), ({ params, request }) => {
311+
if (new URL(request.url).searchParams.get('_method') !== 'DELETE') {
312+
return undefined;
313+
}
314+
const user = activeUser(state);
315+
const membership = state.memberships.find(m => m.organization.id === params.organizationId);
316+
if (!user || !membership) {
317+
return missing();
318+
}
319+
leaveOrganization(state, membership.organization.id, user.id);
320+
return envelope({ object: 'organization_membership', id: membership.id, deleted: true }, state.client);
321+
}),
322+
http.post(fapiUrl('/v1/organizations/:organizationId'), ({ params, request }) => {
323+
if (new URL(request.url).searchParams.get('_method') !== 'DELETE') {
324+
return undefined;
325+
}
326+
const membership = state.memberships.find(m => m.organization.id === params.organizationId);
327+
if (!membership) {
328+
return missing();
329+
}
330+
deleteOrganization(state, membership.organization.id);
331+
return envelope({ object: 'organization', id: membership.organization.id, deleted: true }, state.client);
332+
}),
282333
http.post(fapiUrl('/v1/me/organization_invitations/:id/accept'), ({ params }) => {
283334
const invitation = state.invitations.find(i => i.id === params.id);
284335
if (!invitation) {

‎packages/mosaic/src/blocks/destructive/destructive.controller.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ClerkRuntimeError } from '@clerk/shared/error';
1+
import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error';
22
import { act, renderHook, waitFor } from '@testing-library/react';
33
import { describe, expect, it, vi } from 'vitest';
44

@@ -71,7 +71,7 @@ describe('useDestructiveController', () => {
7171
});
7272
expect(result.current.open).toBe(true);
7373
expect(result.current.isDeleting).toBe(false);
74-
expect(result.current.errorMessage).toBe('Something went wrong');
74+
expect(result.current.errorMessage).toBe('Something went wrong. Please try again.');
7575

7676
await act(async () => {
7777
await result.current.onDelete();
@@ -80,6 +80,34 @@ describe('useDestructiveController', () => {
8080
expect(onDelete).toHaveBeenCalledTimes(2);
8181
});
8282

83+
it('shows the API error when the action is rejected by Clerk', async () => {
84+
const onDelete = vi.fn(() =>
85+
Promise.reject(
86+
new ClerkAPIResponseError('Forbidden', {
87+
status: 403,
88+
data: [
89+
{
90+
code: 'organization_minimum_permissions_needed',
91+
message: 'Short',
92+
long_message: 'There has to be at least one organization member with the minimum required permissions.',
93+
},
94+
],
95+
}),
96+
),
97+
);
98+
const { result } = renderHook(() => useDestructiveController({ onDelete }));
99+
act(() => result.current.onOpenChange(true));
100+
101+
await act(async () => {
102+
await result.current.onDelete();
103+
});
104+
105+
expect(result.current.open).toBe(true);
106+
expect(result.current.errorMessage).toBe(
107+
'There has to be at least one organization member with the minimum required permissions.',
108+
);
109+
});
110+
83111
it('closes without a message when reverification is cancelled', async () => {
84112
const onDelete = vi.fn(() =>
85113
Promise.reject(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })),

‎packages/mosaic/src/blocks/destructive/destructive.controller.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { isReverificationCancelledError } from '@clerk/shared/error';
1+
import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error';
22
import { useState } from 'react';
33

44
import type { ReverificationController } from '../../features/reverification';
5+
import type { LocalizableError } from '../../localization';
6+
import { toLocalizableApiError, useErrorText } from '../../localization';
57
import type { DestructiveControlledProps } from './destructive';
68

79
export type DestructiveController = Pick<
@@ -27,6 +29,7 @@ export function useDestructiveController({
2729
reverification?: ReverificationController;
2830
}): DestructiveController {
2931
const [destructiveState, setDestructiveState] = useState<DestructiveState>({ status: 'closed' });
32+
const errorText = useErrorText();
3033
const { status } = destructiveState;
3134

3235
const openDestructiveDialog = () => {
@@ -57,8 +60,7 @@ export function useDestructiveController({
5760
setDestructiveState({ status: 'closed' });
5861
return;
5962
}
60-
// TODO: Better error handling, localization
61-
setDestructiveState({ status: 'open-error', errorMessage: 'Something went wrong' });
63+
setDestructiveState({ status: 'open-error', errorMessage: errorText(toLocalizableError(error)) });
6264
}
6365
}
6466
},
@@ -77,3 +79,8 @@ export function useDestructiveController({
7779
},
7880
};
7981
}
82+
83+
function toLocalizableError(error: unknown): LocalizableError {
84+
const apiError = isClerkAPIResponseError(error) ? error.errors[0] : undefined;
85+
return apiError ? toLocalizableApiError(apiError) : {};
86+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import type { OrganizationJSON } from '@clerk/shared/types';
2+
import { act, screen, waitFor, within } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
7+
import {
8+
fapiClient,
9+
fapiEnvironment,
10+
fapiMembership,
11+
fapiOrganization,
12+
fapiSession,
13+
fapiUser,
14+
} from '../../../__tests__/feature/fapi';
15+
import { renderWithClerk } from '../../../__tests__/feature/render';
16+
import {
17+
OrganizationProfileDangerSection,
18+
type OrganizationProfileDangerSectionProps,
19+
} from '../organization-profile-danger-section/organization-profile-danger-section';
20+
21+
const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 20 });
22+
const deletePermission = ['org:sys_profile:delete'];
23+
24+
type User = ReturnType<typeof userEvent.setup>;
25+
26+
function signedIn({
27+
organizations = [acme],
28+
permissions = deletePermission,
29+
activeOrganizationId = organizations[0]?.id ?? null,
30+
}: { organizations?: OrganizationJSON[]; permissions?: string[]; activeOrganizationId?: string | null } = {}) {
31+
const memberships = organizations.map(organization => fapiMembership(organization, { permissions }));
32+
const alice = fapiUser({ id: 'user_1', first_name: 'Alice', organization_memberships: memberships });
33+
return {
34+
environment: fapiEnvironment({ display_config: { after_leave_organization_url: '/after-leave' } }),
35+
client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: activeOrganizationId })]),
36+
memberships,
37+
} satisfies FakeFapiSeed;
38+
}
39+
40+
async function renderSection(seed: FakeFapiSeed = signedIn(), props: OrganizationProfileDangerSectionProps = {}) {
41+
const fapi = serveFapi(seed);
42+
const view = await renderWithClerk(<OrganizationProfileDangerSection {...props} />);
43+
return { ...view, fapi };
44+
}
45+
46+
async function openDialog(user: User, action: string) {
47+
await user.click(await screen.findByRole('button', { name: action }));
48+
return screen.getByRole('dialog');
49+
}
50+
51+
async function confirm(user: User, dialog: HTMLElement, action: string, name = 'Acme') {
52+
await user.type(within(dialog).getByRole('textbox'), name);
53+
await user.click(within(dialog).getByRole('button', { name: action }));
54+
}
55+
56+
const memberOf = (seed: { memberships: { organization: { id: string } }[] }, organizationId: string) =>
57+
seed.memberships.some(m => m.organization.id === organizationId);
58+
59+
describe('OrganizationProfileDangerSection', () => {
60+
describe('availability', () => {
61+
it('renders nothing without an active organization', async () => {
62+
const { container } = await renderSection(signedIn({ activeOrganizationId: null }));
63+
64+
expect(container).toBeEmptyDOMElement();
65+
});
66+
67+
it('offers leaving and deleting to a member who can delete the organization', async () => {
68+
await renderSection();
69+
70+
expect(await screen.findByRole('heading', { name: 'Danger zone' })).toBeVisible();
71+
expect(screen.getByRole('button', { name: 'Leave organization' })).toBeVisible();
72+
expect(screen.getByRole('button', { name: 'Delete organization' })).toBeVisible();
73+
});
74+
75+
it('only offers leaving without the delete permission', async () => {
76+
await renderSection(signedIn({ permissions: [] }));
77+
78+
expect(await screen.findByRole('button', { name: 'Leave organization' })).toBeVisible();
79+
expect(screen.queryByRole('button', { name: 'Delete organization' })).toBeNull();
80+
});
81+
82+
it('only offers leaving when admins may not delete the organization', async () => {
83+
await renderSection(
84+
signedIn({ organizations: [fapiOrganization({ id: 'org_1', name: 'Acme', admin_delete_enabled: false })] }),
85+
);
86+
87+
expect(await screen.findByRole('button', { name: 'Leave organization' })).toBeVisible();
88+
expect(screen.queryByRole('button', { name: 'Delete organization' })).toBeNull();
89+
});
90+
});
91+
92+
describe('leaving', () => {
93+
it('leaves once the name is typed, then sends the user to the after-leave url', async () => {
94+
const { fapi, navigate } = await renderSection();
95+
const user = userEvent.setup();
96+
const dialog = await openDialog(user, 'Leave organization');
97+
98+
await confirm(user, dialog, 'Leave organization', 'Acme Inc');
99+
expect(memberOf(fapi, 'org_1')).toBe(true);
100+
101+
await user.clear(within(dialog).getByRole('textbox'));
102+
await confirm(user, dialog, 'Leave organization');
103+
104+
await waitFor(() => expect(memberOf(fapi, 'org_1')).toBe(false));
105+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/after-leave'));
106+
});
107+
108+
it('keeps the dialog open with the Clerk error when leaving fails', async () => {
109+
const { navigate } = await renderSection();
110+
const user = userEvent.setup();
111+
const dialog = await openDialog(user, 'Leave organization');
112+
const leave = holdRequests('post', '/v1/me/organization_memberships/:organizationId');
113+
114+
await confirm(user, dialog, 'Leave organization');
115+
leave.fail(
116+
'organization_minimum_permissions_needed',
117+
'There has to be at least one organization member with the minimum required permissions.',
118+
);
119+
120+
await waitFor(() =>
121+
expect(within(dialog).getByRole('textbox')).toHaveAccessibleDescription(
122+
expect.stringContaining(
123+
'There has to be at least one organization member with the minimum required permissions.',
124+
),
125+
),
126+
);
127+
expect(screen.getByRole('dialog')).toBeVisible();
128+
expect(navigate).not.toHaveBeenCalled();
129+
});
130+
});
131+
132+
describe('deleting', () => {
133+
it('warns how many members are removed', async () => {
134+
await renderSection();
135+
const user = userEvent.setup();
136+
const dialog = await openDialog(user, 'Delete organization');
137+
138+
expect(dialog).toHaveTextContent(
139+
'Are you sure you want to delete Acme? This removes 20 members and permanently deletes all organization data.',
140+
);
141+
});
142+
143+
it('names a single member in the warning', async () => {
144+
await renderSection(signedIn({ organizations: [fapiOrganization({ id: 'org_1', name: 'Acme' })] }));
145+
const user = userEvent.setup();
146+
const dialog = await openDialog(user, 'Delete organization');
147+
148+
expect(dialog).toHaveTextContent('This removes 1 member and permanently deletes all organization data.');
149+
});
150+
151+
it('deletes the organization and sends the user to the url the host passed', async () => {
152+
const { fapi, navigate } = await renderSection(signedIn(), { afterLeaveOrganizationUrl: '/organizations' });
153+
const user = userEvent.setup();
154+
const dialog = await openDialog(user, 'Delete organization');
155+
156+
await confirm(user, dialog, 'Delete organization');
157+
158+
await waitFor(() => expect(memberOf(fapi, 'org_1')).toBe(false));
159+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
160+
});
161+
162+
it('holds the dialog open while the delete is in flight', async () => {
163+
await renderSection();
164+
const user = userEvent.setup();
165+
const dialog = await openDialog(user, 'Delete organization');
166+
const destroy = holdRequests('post', '/v1/organizations/:organizationId');
167+
168+
await confirm(user, dialog, 'Delete organization');
169+
170+
await waitFor(() =>
171+
expect(within(dialog).getByRole('button', { name: 'Delete organization' })).toHaveAttribute(
172+
'aria-busy',
173+
'true',
174+
),
175+
);
176+
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
177+
expect(screen.getByRole('dialog')).toBeVisible();
178+
179+
destroy.release();
180+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
181+
});
182+
183+
it('drops a pending confirmation when the active organization changes', async () => {
184+
const twin = fapiOrganization({ id: 'org_2', name: 'Acme', members_count: 20 });
185+
const { clerk, fapi } = await renderSection(signedIn({ organizations: [acme, twin] }));
186+
const user = userEvent.setup();
187+
const dialog = await openDialog(user, 'Delete organization');
188+
await user.type(within(dialog).getByRole('textbox'), 'Acme');
189+
190+
await act(() => clerk.setActive({ organization: 'org_2' }));
191+
192+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
193+
const reopened = await openDialog(user, 'Delete organization');
194+
expect(within(reopened).getByRole('textbox')).toHaveValue('');
195+
expect(within(reopened).getByRole('button', { name: 'Delete organization' })).not.toHaveAttribute(
196+
'aria-busy',
197+
'true',
198+
);
199+
expect(memberOf(fapi, 'org_1')).toBe(true);
200+
expect(memberOf(fapi, 'org_2')).toBe(true);
201+
});
202+
});
203+
});

0 commit comments

Comments
 (0)