From 3db939a74c6400132ede97b97febc1caeec723ea Mon Sep 17 00:00:00 2001 From: vedanth Date: Tue, 27 Oct 2026 07:55:34 +0000 Subject: [PATCH] fix(mobile): keep organization settings with the active account --- apps/mobile/app/organization-settings.tsx | 41 +++- .../screens/organization-settings.test.tsx | 184 ++++++++++++++++++ 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/apps/mobile/app/organization-settings.tsx b/apps/mobile/app/organization-settings.tsx index bb960ee2c44..2d246f82152 100644 --- a/apps/mobile/app/organization-settings.tsx +++ b/apps/mobile/app/organization-settings.tsx @@ -63,6 +63,23 @@ const openPhotoSettings = () => { export default function OrganizationSettingsScreen() { const auth = useAuth(); + return ( + + ); +} + +function OrganizationSettingsForAccount() { + const auth = useAuth(); + const activeOrganizationId = auth.bootstrap?.activeOrganizationId; + const activeRef = useRef(true); + useEffect(() => { + activeRef.current = true; + return () => { + activeRef.current = false; + }; + }, []); const [settings, setSettings] = useState( null, ); @@ -86,6 +103,13 @@ export default function OrganizationSettingsScreen() { .getOrganizationSettings() .then((response) => { if (requestRef.current !== requestId) return; + if (activeOrganizationId && response.id !== activeOrganizationId) { + setSettings(null); + setError( + "The active organization changed. Refresh your account and try again.", + ); + return; + } setSettings(response); setName(response.name); setAllowedEmailDomain(response.allowedEmailDomain ?? ""); @@ -98,7 +122,7 @@ export default function OrganizationSettingsScreen() { .finally(() => { if (requestRef.current === requestId) setLoading(false); }); - }, [auth.client, auth.status]); + }, [activeOrganizationId, auth.client, auth.status]); useEffect(() => { load(); @@ -114,7 +138,8 @@ export default function OrganizationSettingsScreen() { const busy = saving || iconBusy; const save = async () => { - if (!settings?.canManage || saving || !name.trim()) return; + if (!activeRef.current || !settings?.canManage || saving || !name.trim()) + return; setSaving(true); setError(null); try { @@ -122,6 +147,7 @@ export default function OrganizationSettingsScreen() { name: name.trim(), allowedEmailDomain: allowedEmailDomain.trim() || null, }); + if (!activeRef.current) return; setSettings(response); setName(response.name); setAllowedEmailDomain(response.allowedEmailDomain ?? ""); @@ -134,11 +160,12 @@ export default function OrganizationSettingsScreen() { }; const chooseIcon = async () => { - if (!settings?.canManage || busy) return; + if (!activeRef.current || !settings?.canManage || busy) return; let result: ImagePicker.ImagePickerResult; try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!activeRef.current) return; if (!permission.granted) { openPhotoSettings(); return; @@ -151,10 +178,11 @@ export default function OrganizationSettingsScreen() { quality: 0.8, }); } catch { + if (!activeRef.current) return; setError("Photos could not be opened. Check access and try again."); return; } - if (result.canceled) return; + if (!activeRef.current || result.canceled) return; const asset = result.assets[0]; if (!asset?.base64) { setError("The selected image could not be read. Choose another image."); @@ -191,6 +219,7 @@ export default function OrganizationSettingsScreen() { contentType, fileName, }); + if (!activeRef.current) return; setSettings(response); void auth.refresh().catch(() => undefined); } catch (cause) { @@ -201,11 +230,13 @@ export default function OrganizationSettingsScreen() { }; const removeIcon = async () => { - if (!settings?.canManage || !settings.iconUrl || busy) return; + if (!activeRef.current || !settings?.canManage || !settings.iconUrl || busy) + return; setIconBusy(true); setError(null); try { const response = await auth.client.removeOrganizationIcon(); + if (!activeRef.current) return; setSettings(response); void auth.refresh().catch(() => undefined); } catch (cause) { diff --git a/apps/mobile/src/screens/organization-settings.test.tsx b/apps/mobile/src/screens/organization-settings.test.tsx index c761263f077..0fa498f8858 100644 --- a/apps/mobile/src/screens/organization-settings.test.tsx +++ b/apps/mobile/src/screens/organization-settings.test.tsx @@ -28,6 +28,7 @@ const settings = { const auth = vi.hoisted(() => ({ value: { status: "signedIn" as const, + bootstrap: { activeOrganizationId: "org_123" }, client: { getOrganizationSettings: vi.fn(), removeOrganizationIcon: vi.fn(), @@ -167,6 +168,7 @@ vi.mock("@/components/CapLoadingIndicator", async () => { describe("OrganizationSettingsScreen", () => { beforeEach(() => { + auth.value.bootstrap.activeOrganizationId = "org_123"; auth.value.client.getOrganizationSettings.mockReset(); auth.value.client.getOrganizationSettings.mockResolvedValue(settings); auth.value.client.updateOrganizationSettings.mockReset(); @@ -219,4 +221,186 @@ describe("OrganizationSettingsScreen", () => { }); expect(auth.value.refresh).toHaveBeenCalledTimes(1); }); + + it("replaces drafts when the active organization changes", async () => { + const renderer = await renderComponent( + React.createElement(OrganizationSettingsScreen), + ); + const input = renderer.root.findByProps({ + accessibilityLabel: "Organization name", + }); + await act(async () => input.props.onChangeText("Unfinished A")); + auth.value.client.getOrganizationSettings.mockResolvedValueOnce({ + ...settings, + id: "org_b", + name: "Organization B", + }); + auth.value.bootstrap.activeOrganizationId = "org_b"; + await act(async () => { + renderer.update(React.createElement(OrganizationSettingsScreen)); + await Promise.resolve(); + await Promise.resolve(); + }); + expect( + renderer.root.findByProps({ accessibilityLabel: "Organization name" }) + .props.value, + ).toBe("Organization B"); + await act(async () => renderer.unmount()); + }); + + it("ignores an old settings save after switching organizations", async () => { + let resolveSave!: (value: typeof settings) => void; + auth.value.client.updateOrganizationSettings.mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const renderer = await renderComponent( + React.createElement(OrganizationSettingsScreen), + ); + await act(async () => + renderer.root + .findByProps({ accessibilityLabel: "Organization name" }) + .props.onChangeText("A draft"), + ); + await act(async () => { + renderer.root + .findByProps({ accessibilityLabel: "Save changes" }) + .props.onPress(); + }); + auth.value.client.getOrganizationSettings.mockResolvedValueOnce({ + ...settings, + id: "org_b", + name: "Organization B", + }); + auth.value.bootstrap.activeOrganizationId = "org_b"; + await act(async () => { + renderer.update(React.createElement(OrganizationSettingsScreen)); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + resolveSave({ ...settings, name: "A saved" }); + await Promise.resolve(); + }); + expect( + renderer.root.findByProps({ accessibilityLabel: "Organization name" }) + .props.value, + ).toBe("Organization B"); + await act(async () => renderer.unmount()); + }); + + it("does not upload a photo selected for a previous organization", async () => { + const { ActionSheetIOS } = await import("react-native"); + const picker = await import("expo-image-picker"); + let finishPicker!: ( + value: Awaited>, + ) => void; + vi.mocked(picker.requestMediaLibraryPermissionsAsync).mockResolvedValue({ + granted: true, + } as Awaited< + ReturnType + >); + vi.mocked(picker.launchImageLibraryAsync).mockReturnValueOnce( + new Promise((resolve) => { + finishPicker = resolve; + }), + ); + auth.value.client.updateOrganizationIcon + .mockReset() + .mockResolvedValue(settings); + const renderer = await renderComponent( + React.createElement(OrganizationSettingsScreen), + ); + await act(async () => { + renderer.root + .findByProps({ accessibilityLabel: "Organization icon" }) + .props.onPress(); + }); + const choose = vi + .mocked(ActionSheetIOS.showActionSheetWithOptions) + .mock.calls.at(-1)?.[1]; + if (!choose) throw new Error("Missing icon action"); + await act(async () => { + choose(0); + await Promise.resolve(); + }); + auth.value.client.getOrganizationSettings.mockResolvedValueOnce({ + ...settings, + id: "org_b", + name: "Organization B", + }); + auth.value.bootstrap.activeOrganizationId = "org_b"; + await act(async () => { + renderer.update(React.createElement(OrganizationSettingsScreen)); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + finishPicker({ + canceled: false, + assets: [ + { + uri: "file:///photo.jpg", + width: 10, + height: 10, + base64: "abcd", + mimeType: "image/jpeg", + }, + ], + }); + await Promise.resolve(); + }); + expect(auth.value.client.updateOrganizationIcon).not.toHaveBeenCalled(); + await act(async () => renderer.unmount()); + }); + it("refuses to show editable settings for a different server organization", async () => { + auth.value.client.getOrganizationSettings.mockResolvedValueOnce({ + ...settings, + id: "org_b", + name: "Organization B", + }); + const renderer = await renderComponent( + React.createElement(OrganizationSettingsScreen), + ); + expect(getTextNodes(renderer.toJSON()).join(" ")).toContain( + "The active organization changed", + ); + expect( + renderer.root.findAllByProps({ accessibilityLabel: "Organization name" }), + ).toHaveLength(0); + await act(async () => renderer.unmount()); + }); + + it("ignores a previous organization's delayed icon action", async () => { + const { ActionSheetIOS } = await import("react-native"); + const picker = await import("expo-image-picker"); + vi.mocked(picker.requestMediaLibraryPermissionsAsync).mockReset(); + const renderer = await renderComponent( + React.createElement(OrganizationSettingsScreen), + ); + await act(async () => + renderer.root + .findByProps({ accessibilityLabel: "Organization icon" }) + .props.onPress(), + ); + const choose = vi + .mocked(ActionSheetIOS.showActionSheetWithOptions) + .mock.calls.at(-1)?.[1]; + if (!choose) throw new Error("Missing icon action"); + auth.value.client.getOrganizationSettings.mockResolvedValueOnce({ + ...settings, + id: "org_b", + name: "Organization B", + }); + auth.value.bootstrap.activeOrganizationId = "org_b"; + await act(async () => { + renderer.update(React.createElement(OrganizationSettingsScreen)); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => choose(0)); + expect(picker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled(); + await act(async () => renderer.unmount()); + }); });