diff --git a/.changeset/187-mdm-lock-scope.md b/.changeset/187-mdm-lock-scope.md new file mode 100644 index 0000000..27fa284 --- /dev/null +++ b/.changeset/187-mdm-lock-scope.md @@ -0,0 +1,17 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +While the organization's MDM locks the server (`lockServer`), the "Switch +organization" sheet lists only the managed connection. Connections saved +before the lock are hidden, not deleted: they cannot be switched to or removed +while locked, and are listed again once the lock is lifted. The managed +connection is still made current when the lock arrives, and connect links and +the scanner stay refused while locked. + +Docs: the MDM key table in `docs/DEPLOYMENT.md` states that `trustedHosts` is +not supported, and `docs/HYBRID-SHELL.md` lists as a known limit that a route +asked for while the main frame is on another origin is dropped. diff --git a/__tests__/components/ConnectionSwitcher.test.tsx b/__tests__/components/ConnectionSwitcher.test.tsx index 5f77248..529be8d 100644 --- a/__tests__/components/ConnectionSwitcher.test.tsx +++ b/__tests__/components/ConnectionSwitcher.test.tsx @@ -193,24 +193,25 @@ describe("ConnectionSwitcher", () => { expect(props.onOpenSettings).toHaveBeenCalled(); }); - it("while the organization locks the server: no add, and no Remove on the managed connection", async () => { + it("while the organization locks the server: only the managed connection, no add, and no Remove on it", async () => { const props = renderSwitcher({ lockedHost: "erp.example.com" }); expect(screen.queryByTestId("switcher-add")).toBeNull(); expect(screen.queryByTestId("switcher-remove-erp.example.com")).toBeNull(); - const [erpSwipe, acmeSwipe] = screen.getAllByTestId("swipeable"); - expect(erpSwipe).toHaveProp("accessibilityState", { disabled: true }); - expect(acmeSwipe).toHaveProp("accessibilityState", { disabled: false }); + // The other saved connections are not listed, so they can be neither switched to nor removed. + expect(screen.queryByTestId("switcher-row-acme.objectos.ai")).toBeNull(); + expect(screen.queryByTestId("switcher-remove-acme.objectos.ai")).toBeNull(); + const swipes = screen.getAllByTestId("swipeable"); + expect(swipes).toHaveLength(1); + expect(swipes[0]).toHaveProp("accessibilityState", { disabled: true }); const erp = screen.getByTestId("switcher-row-erp.example.com"); expect(erp).toHaveProp("accessibilityActions", []); fireEvent(erp, "accessibilityAction", { nativeEvent: { actionName: "remove" } }); expect(props.onRemove).not.toHaveBeenCalled(); - // The managed row still switches; the other connections stay removable. + // The managed row still switches. fireEvent.press(erp); expect(props.onSelect).toHaveBeenCalledWith("erp.example.com"); - fireEvent.press(screen.getByTestId("switcher-remove-acme.objectos.ai")); - expect(props.onRemove).toHaveBeenCalledWith("acme.objectos.ai"); expect(screen.getByTestId("switcher-footnote")).toHaveTextContent( "Each organization keeps its own sign-in. Example ERP is set up by your organization and can't be removed." @@ -222,6 +223,34 @@ describe("ConnectionSwitcher", () => { expect(screen.queryByText("添加组织或公司服务器")).toBeNull(); }); + it("lists every saved connection again once the lock is lifted", () => { + const state = saved(); + const props: ConnectionSwitcherProps = { + open: true, + connections: state.list, + currentHost: state.current, + lockedHost: null, + onClose: jest.fn(), + onSelect: jest.fn(), + onRemove: jest.fn(), + onAdd: jest.fn(), + onOpenSettings: jest.fn(), + }; + const hosts = () => + screen.getAllByTestId(/^switcher-row-/).map((row) => row.props.testID as string); + const view = render(); + expect(hosts()).toEqual(["switcher-row-erp.example.com", "switcher-row-acme.objectos.ai"]); + + view.rerender(); + expect(hosts()).toEqual(["switcher-row-erp.example.com"]); + + view.rerender(); + expect(hosts()).toEqual(["switcher-row-erp.example.com", "switcher-row-acme.objectos.ai"]); + expect(screen.getByTestId("switcher-add")).toBeTruthy(); + fireEvent.press(screen.getByTestId("switcher-remove-acme.objectos.ai")); + expect(props.onRemove).toHaveBeenCalledWith("acme.objectos.ai"); + }); + it("shows only the add action when nothing is saved", () => { renderSwitcher({ connections: [], currentHost: null }); expect(screen.queryAllByTestId(/^switcher-row-/)).toEqual([]); diff --git a/__tests__/components/ConsoleScreen.test.tsx b/__tests__/components/ConsoleScreen.test.tsx index 1851ba3..b62f817 100644 --- a/__tests__/components/ConsoleScreen.test.tsx +++ b/__tests__/components/ConsoleScreen.test.tsx @@ -342,14 +342,34 @@ describe("ConsoleScreen under a managed deployment (MDM)", () => { expect(useServerStore.getState().currentHost).toBe("erp.example.com"); }); - it("locked: the other saved connections can still be removed", async () => { - useServerStore.setState({ managed: LOCKED }); + it("locked: the switcher lists only the managed connection; the others stay saved and return when the lock is lifted", async () => { + // On Acme when the lock arrives: the managed connection is made current. + useServerStore.setState({ currentHost: "acme.objectos.ai", serverUrl: ACME }); render(); - await openSwitcherFromConsole(); - await act(async () => { - fireEvent.press(screen.getByTestId("switcher-remove-acme.objectos.ai")); + act(() => { + useServerStore.getState().applyManaged(LOCKED); }); - expect(useServerStore.getState().connections.map((c) => c.host)).toEqual(["erp.example.com"]); + expect(useServerStore.getState().currentHost).toBe("erp.example.com"); + + await openSwitcherFromConsole(ERP); + expect(screen.getByTestId("switcher-row-erp.example.com")).toBeTruthy(); + expect(screen.queryByTestId("switcher-row-acme.objectos.ai")).toBeNull(); + expect(screen.queryByTestId("switcher-remove-acme.objectos.ai")).toBeNull(); + // Hidden, not removed: nothing signed out, nothing dropped from the store. + expect(useServerStore.getState().connections.map((c) => c.host)).toEqual([ + "erp.example.com", + "acme.objectos.ai", + ]); + expect(mockClearHostCookies).not.toHaveBeenCalled(); + + // The configuration is removed: the lock is lifted and Acme is listed again. + act(() => { + useServerStore.getState().applyManaged(NO_MANAGED_CONFIG); + }); + expect(screen.getByTestId("switcher-row-acme.objectos.ai")).toBeTruthy(); + expect(screen.getByTestId("switcher-add")).toBeTruthy(); + expect(useServerStore.getState().connections).toHaveLength(2); + expect(useServerStore.getState().currentHost).toBe("erp.example.com"); }); it("a managed address that waits for its confirm step hands over to the picker, before any WebView", () => { diff --git a/app/console.tsx b/app/console.tsx index 95f4e60..6bcca52 100644 --- a/app/console.tsx +++ b/app/console.tsx @@ -36,8 +36,9 @@ const APP_LOCK_SETTINGS = "/app-lock-settings"; * * Managed deployment: while the MDM's server address waits for its probe and * confirm step (first launch, or a new address pushed while the app runs), the - * picker takes over. While the server is locked, the managed connection - * cannot be removed (switcher, `resetServer()`), and nothing can be added. + * picker takes over. While the server is locked, the switcher lists the + * managed connection alone (the others stay saved), it cannot be removed + * (switcher, `resetServer()`), and nothing can be added. * * The switcher's "App lock" row and the bridge's `openAppLockSettings()` open * the native app-lock settings screen; the bridge reads the setting with diff --git a/components/shell/ConnectionSwitcher.tsx b/components/shell/ConnectionSwitcher.tsx index 6bbb78a..95309eb 100644 --- a/components/shell/ConnectionSwitcher.tsx +++ b/components/shell/ConnectionSwitcher.tsx @@ -20,7 +20,9 @@ export interface ConnectionSwitcherProps { removingHost?: string | null; /** * The managed connection while the organization locks the server - * (`lockServer`): its row cannot be removed, and adding is not offered. + * (`lockServer`): it is the only row listed, it cannot be removed, and + * adding is not offered. The other saved connections are hidden, not + * removed: they are listed again once the lock is lifted. */ lockedHost?: string | null; onClose: () => void; @@ -48,8 +50,8 @@ const LIST_MAX_HEIGHT = 400; * reveals Remove (screen readers get the same as a "Remove" action on the * row). Below: add a connection, which never saves by * itself — it goes back to the address step and its confirm step — and app - * settings. While the server is locked by the organization, the managed row - * has no Remove and there is no add. + * settings. While the server is locked by the organization, only the managed + * row is listed, it has no Remove, and there is no add. * * "App lock" opens the native app-lock settings screen: the one place the * lock is changed, behind biometrics. (Unrelated to the organization's server @@ -73,8 +75,11 @@ export function ConnectionSwitcher({ const colors = useThemeColors(); const direction = useLayoutDirection(); const Chevron = direction === "rtl" ? ChevronLeft : ChevronRight; - const lockedLabel = - lockedHost === null ? null : (connections.find((c) => c.host === lockedHost)?.label ?? null); + const lockedConnection = + lockedHost === null ? null : (connections.find((c) => c.host === lockedHost) ?? null); + const lockedLabel = lockedConnection?.label ?? null; + // Locked: the managed connection only. The others stay saved, unlisted. + const listed = lockedConnection === null ? connections : [lockedConnection]; return ( (next ? undefined : onClose())}> @@ -92,7 +97,7 @@ export function ConnectionSwitcher({ className="rounded-2xl border border-border" bounces={false} > - {connections.map((connection, index) => ( + {listed.map((connection, index) => (