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) => (