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