diff --git a/src/components/layout/MainPanel.tsx b/src/components/layout/MainPanel.tsx index 74966791c..66dc0b032 100644 --- a/src/components/layout/MainPanel.tsx +++ b/src/components/layout/MainPanel.tsx @@ -1,13 +1,10 @@ -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useSessionStore } from "@/stores/sessionStore"; import { sessionClosed } from "@/stores/reconnectBackoff"; import { useUIStore } from "@/stores/uiStore"; import { useVaultStore } from "@/stores/vaultStore"; -import { useTeamStore } from "@/stores/teamStore"; -import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; -import { fetchTeamData } from "@/services/teamVaultSync"; -import { ownerHandle, selectedTeamId } from "@/services/teamVaultFirstAccess"; +import { useBlockedTeamVault } from "@/hooks/useBlockedTeamVault"; +import TeamVaultStatePanel from "@/components/team/TeamVaultStatePanel"; import MultiplayerTerminalView from "@/components/terminal/MultiplayerTerminalView"; import { MultiplayerBar } from "@/components/terminal/MultiplayerBar"; import { HostAwareTerminalView, SessionConnectionOverlay } from "@/components/terminal/SessionView"; @@ -55,123 +52,8 @@ function NoVaultSelected() { ); } -function TeamVaultState({ - status, - teamId, -}: { - status: string; - teamId: string; -}) { - const { t } = useTranslation(); - const team = useTeamStore((s) => s.teams.find((t) => t.id === teamId)); - const rolesByTeam = useTeamStore((s) => s.rolesByTeam); - const members = useTeamStore((s) => s.membersByTeam[teamId]); - const loadMembers = useTeamStore((s) => s.loadMembers); - const myRoleIds = team?.role_ids ?? []; - const teamRoles = rolesByTeam[teamId] ?? []; - const isOwner = myRoleIds.some((rid) => { - const r = teamRoles.find((role) => role.id === rid); - return r?.is_builtin && r.name === "owner"; - }); - - // The waiting copy names the owner the user is waiting on, so the roster has - // to be there — this panel replaces the pages that would otherwise load it. - useEffect(() => { - if (status === "awaiting_key" && !members) loadMembers(teamId).catch(() => {}); - }, [status, members, teamId, loadMembers]); - - // Generic until the handle resolves: a name flashing in from blank reads worse - // than the sentence that never had one. - const owner = ownerHandle(team, members); - - const configs: Record = { - offline: { - icon: "lucide:cloud-off", - title: t("layout.mainPanel.teamVault.offlineTitle"), - body: t("layout.mainPanel.teamVault.offlineBody"), - }, - forbidden: { - icon: "lucide:shield-off", - title: t("layout.mainPanel.teamVault.forbiddenTitle"), - body: t("layout.mainPanel.teamVault.forbiddenBody"), - }, - // Member has joined the team but no vault owner has distributed a key yet - // (issue #41). Distinct from a hard error — a key-holder self-heals this on - // their next sync, so present it as a benign waiting state, not a failure. - awaiting_key: { - icon: "lucide:clock", - title: t("layout.mainPanel.teamVault.waitingForAccessTitle"), - body: owner - ? t("layout.mainPanel.teamVault.waitingForAccessBodyNamed", { owner: `@${owner}` }) - : t("layout.mainPanel.teamVault.waitingForAccessBody"), - }, - payment_required: { - icon: "lucide:credit-card", - title: t("layout.mainPanel.teamVault.paymentRequiredTitle"), - body: isOwner - ? t("layout.mainPanel.teamVault.paymentRequiredBodyOwner") - : t("layout.mainPanel.teamVault.paymentRequiredBodyMember"), - }, - error: { - icon: "lucide:triangle-alert", - title: t("layout.mainPanel.teamVault.errorTitle"), - body: t("layout.mainPanel.teamVault.errorBody"), - }, - }; - - const cfg = configs[status] ?? configs.error; - - const openBilling = () => { - useUIStore.getState().openSettings("account"); - }; - - return ( -
-
- -
-
- {cfg.title} - {cfg.body} - {status === "payment_required" && isOwner && ( - - )} - {(!status || status === "error" || status === "awaiting_key") && ( - - )} -
-
- ); -} - const PLACEHOLDER_PAGES: Record = {}; -function useSelectedTeamId(): string | null { - const selectedVaultIds = useVaultStore((s) => s.selectedVaultIds); - const vaults = useVaultStore((s) => s.vaults); - const teams = useTeamStore((s) => s.teams); - - return selectedTeamId(selectedVaultIds, vaults, teams); -} - export default function MainPanel() { const { sessions, activeSessionId } = useSessionStore(); const reconnect = useSessionStore((s) => s.reconnect); @@ -193,18 +75,8 @@ export default function MainPanel() { useHostPingPolling(); // Check if selected vault is a team vault in a non-loaded state - const selectedTeamId = useSelectedTeamId(); - const teamVaultStatus = useTeamVaultStateStore( - (s) => selectedTeamId ? s.statusByTeamId[selectedTeamId] : null, - ); - const showTeamVaultState = - selectedTeamId !== null && - (teamVaultStatus === "offline" || - teamVaultStatus === "forbidden" || - teamVaultStatus === "payment_required" || - teamVaultStatus === "awaiting_key" || - teamVaultStatus === "error") && - !homeView; + const blockedTeamVault = useBlockedTeamVault(); + const showTeamVaultState = blockedTeamVault !== null && !homeView; const showSplitWorkspace = activeNav === "terminal" && splitTabActive && !sftpPanelOpen; // Determine vault/home overlay to show on top of terminals @@ -240,7 +112,7 @@ export default function MainPanel() { ) : showTeamVaultState ? (
- +
) : sessions.length === 0 && !showSplitWorkspace ? (
diff --git a/src/components/mobile/MobileShell.teamVault.test.tsx b/src/components/mobile/MobileShell.teamVault.test.tsx new file mode 100644 index 000000000..097cf4a22 --- /dev/null +++ b/src/components/mobile/MobileShell.teamVault.test.tsx @@ -0,0 +1,85 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => (o?.owner ? `${k} ${o.owner}` : k), + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/services/teamVaultSync", () => ({ fetchTeamData: vi.fn(async () => {}) })); + +// The shell's own chrome and every tab screen are irrelevant here: this asserts +// which of them the blocked-vault panel replaces, not what they render. +vi.mock("./MobileSessionLayer", () => ({ default: () => null })); +vi.mock("./screens/MobileHostsScreen", () => ({ default: () =>
hosts-screen
})); +vi.mock("./screens/MobileSnippetsScreen", () => ({ default: () =>
snippets-screen
})); +vi.mock("./screens/MobileTerminalScreen", () => ({ default: () =>
terminal-screen
})); +vi.mock("./panels/MobileSftpScreen", () => ({ default: () =>
sftp-screen
})); + +import MobileShell from "./MobileShell"; +import { useTeamStore } from "@/stores/teamStore"; +import { useVaultStore } from "@/stores/vaultStore"; +import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; +import { useMobileNavStore } from "@/stores/mobileNavStore"; +import { useSessionStore } from "@/stores/sessionStore"; +import type { Team } from "@/services/teamService"; + +const TEAM: Team = { + id: "t1", name: "Ops", owner_id: "u9", owner_tier: "team", created_at: "", role_ids: [], +}; + +beforeEach(() => { + useTeamStore.setState({ + teams: [TEAM], + membersByTeam: { t1: [] }, + rolesByTeam: { t1: [] }, + }); + useVaultStore.setState({ vaults: [], selectedVaultIds: ["t1"] }); + useTeamVaultStateStore.setState({ statusByTeamId: {} }); + useMobileNavStore.setState({ tab: "hosts", stack: [], sheet: null }); + useSessionStore.setState({ sessions: [] }); +}); +afterEach(cleanup); + +test("a keyless team vault explains itself instead of showing an empty hosts list", () => { + useTeamVaultStateStore.setState({ statusByTeamId: { t1: "awaiting_key" } }); + + render(); + + expect(screen.getByText("layout.mainPanel.teamVault.waitingForAccessTitle")).toBeTruthy(); + expect(screen.queryByText("hosts-screen")).toBeNull(); +}); + +test("the blocked vault is escapable — the vault switcher stays reachable", () => { + useTeamVaultStateStore.setState({ statusByTeamId: { t1: "awaiting_key" } }); + + const { container } = render(); + + // MobileHeader is the only route to the vault switcher on mobile, and it + // lives inside the tab screens the panel replaces. Without it a member who + // opens a keyless vault cannot get back to any other one. + expect(container.querySelector("[data-mobile-vault-switch]")).not.toBeNull(); +}); + +test("the blocked panel never hides the tab bar", () => { + useSessionStore.setState({ sessions: [{ id: "s1" }] as never }); + useMobileNavStore.setState({ tab: "terminal" }); + useTeamVaultStateStore.setState({ statusByTeamId: { t1: "awaiting_key" } }); + + const { container } = render(); + + // Immersive mode hides the tab bar for a live terminal; with the panel on top + // of it that would leave no navigation at all. + expect(container.querySelector("[data-mobile-tab]")).not.toBeNull(); +}); + +test("a loaded team vault shows its pages", () => { + useTeamVaultStateStore.setState({ statusByTeamId: { t1: "loaded" } }); + + render(); + + expect(screen.getByText("hosts-screen")).toBeTruthy(); + expect(screen.queryByText("layout.mainPanel.teamVault.waitingForAccessTitle")).toBeNull(); +}); diff --git a/src/components/mobile/MobileShell.tsx b/src/components/mobile/MobileShell.tsx index 248d768e3..898e86732 100644 --- a/src/components/mobile/MobileShell.tsx +++ b/src/components/mobile/MobileShell.tsx @@ -19,6 +19,8 @@ import MobileLogsScreen from "./screens/MobileLogsScreen"; import MobileSftpScreen from "./panels/MobileSftpScreen"; import MobileAccountPage from "./screens/MobileAccountPage"; import MobilePanelHeader from "./panels/MobilePanelHeader"; +import MobileHeader from "./MobileHeader"; +import TeamVaultStatePanel from "@/components/team/TeamVaultStatePanel"; import MobileSnippetTargetSheet from "./sheets/MobileSnippetTargetSheet"; import MobileSnippetActionsSheet from "./sheets/MobileSnippetActionsSheet"; import MobileSnippetsSheet from "./sheets/MobileSnippetsSheet"; @@ -43,6 +45,7 @@ import { resolvePanelScreen } from "./mobilePanelDispatch"; import { useAndroidBack } from "@/hooks/useAndroidBack"; import { useVisualViewport } from "@/hooks/useVisualViewport"; import { useHostPingPolling } from "@/hooks/useHostPingPolling"; +import { useBlockedTeamVault } from "@/hooks/useBlockedTeamVault"; import { refitSession } from "@/hooks/useTerminal"; export default function MobileShell() { @@ -71,8 +74,14 @@ export default function MobileShell() { }; const resolvedPanel = resolvePanelScreen(top); + // A team vault that cannot show its contents replaces the vault's screens with + // the explanatory panel, the way MainPanel does on desktop (issue #70). + const blockedTeamVault = useBlockedTeamVault(); + // Terminal tab with sessions = immersive: hide the tab bar, give xterm every pixel. - const immersive = tab === "terminal" && hasSessions && !top; + // Never while the blocked panel is up — it covers the screens, so dropping the tab + // bar as well would leave the member no way off the vault at all. + const immersive = tab === "terminal" && hasSessions && !top && !blockedTeamVault; const terminalVisible = tab === "terminal" && !top; // SFTP tab is always-mounted (below) so its connections/cwd survive tab switches; this only gates visibility. const sftpVisible = !terminalVisible && tab === "sftp" && !top; @@ -107,7 +116,10 @@ export default function MobileShell() { {/* Always-mounted sessions; visibility toggled so xterm survives tab switches */} {/* Non-terminal tab content layers above the session layer when terminal isn't foreground */} - {!terminalVisible && tab !== "sftp" && ( + {/* Not while the vault is blocked: these screens list vault objects, and the + panel covers them anyway. The session and SFTP layers below stay mounted — + unmounting them would drop a live terminal or an SFTP connection. */} + {!terminalVisible && tab !== "sftp" && !blockedTeamVault && (
{tab === "hosts" && !top && } {tab === "snippets" && !top && } @@ -141,6 +153,16 @@ export default function MobileShell() { {resolvedPanel && renderMobileScreen(resolvedPanel.screenKind, resolvedPanel.props)} {top?.kind === "panel-sftp" && } {top?.kind === "account" && } + {/* Above every screen and pushed page — all of them read vault objects this + member cannot see yet. MobileHeader rides along because it owns the vault + switcher: desktop leaves its sidebar uncovered, and this is the mobile + equivalent of that escape route. */} + {blockedTeamVault && ( +
+ + +
+ )}
{/* Hide the tab bar while a full-screen page is pushed — it would otherwise sit visible-but-covered under the overlay, and tapping a tab silently clears the stack. */} diff --git a/src/components/team/TeamVaultStatePanel.test.tsx b/src/components/team/TeamVaultStatePanel.test.tsx new file mode 100644 index 000000000..152b2483b --- /dev/null +++ b/src/components/team/TeamVaultStatePanel.test.tsx @@ -0,0 +1,52 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => (o?.owner ? `${k} ${o.owner}` : k), + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/services/teamVaultSync", () => ({ fetchTeamData: vi.fn(async () => {}) })); + +import TeamVaultStatePanel from "./TeamVaultStatePanel"; +import { useTeamStore } from "@/stores/teamStore"; +import type { Team, TeamMember } from "@/services/teamService"; + +const TEAM: Team = { + id: "t1", name: "Ops", owner_id: "u9", owner_tier: "team", created_at: "", role_ids: [], +}; +function member(user_id: string, handle?: string): TeamMember { + return { + team_id: "t1", user_id, handle, public_key: "pk", + invited_by_display_name: null, joined_at: "", role_ids: [], + }; +} + +beforeEach(() => { + useTeamStore.setState({ teams: [TEAM], membersByTeam: {}, rolesByTeam: { t1: [] } }); +}); +afterEach(cleanup); + +test("the waiting copy names the owner the member is waiting on", () => { + useTeamStore.setState({ membersByTeam: { t1: [member("u9", "bob")] } }); + + render(); + + expect(screen.getByText("layout.mainPanel.teamVault.waitingForAccessBodyNamed @bob")).toBeTruthy(); +}); + +test("the waiting copy stays generic while the owner's handle is unknown", () => { + useTeamStore.setState({ membersByTeam: { t1: [member("u9")] } }); + + render(); + + expect(screen.getByText("layout.mainPanel.teamVault.waitingForAccessBody")).toBeTruthy(); +}); + +test("an unrecognised status falls back to the generic error", () => { + render(); + + expect(screen.getByText("layout.mainPanel.teamVault.errorTitle")).toBeTruthy(); +}); diff --git a/src/components/team/TeamVaultStatePanel.tsx b/src/components/team/TeamVaultStatePanel.tsx new file mode 100644 index 000000000..8ce4d49f3 --- /dev/null +++ b/src/components/team/TeamVaultStatePanel.tsx @@ -0,0 +1,120 @@ +/** + * The panel a shell shows in place of a team vault's pages when the vault + * cannot show its contents (issue #70). Shared by MainPanel and MobileShell so + * the copy, the statuses and the recovery actions exist once. + */ + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { useTeamStore } from "@/stores/teamStore"; +import { useUIStore } from "@/stores/uiStore"; +import { fetchTeamData } from "@/services/teamVaultSync"; +import { ownerHandle } from "@/services/teamVaultFirstAccess"; + +export default function TeamVaultStatePanel({ + status, + teamId, +}: { + status: string; + teamId: string; +}) { + const { t } = useTranslation(); + const team = useTeamStore((s) => s.teams.find((t) => t.id === teamId)); + const rolesByTeam = useTeamStore((s) => s.rolesByTeam); + const members = useTeamStore((s) => s.membersByTeam[teamId]); + const loadMembers = useTeamStore((s) => s.loadMembers); + const myRoleIds = team?.role_ids ?? []; + const teamRoles = rolesByTeam[teamId] ?? []; + const isOwner = myRoleIds.some((rid) => { + const r = teamRoles.find((role) => role.id === rid); + return r?.is_builtin && r.name === "owner"; + }); + + // The waiting copy names the owner the user is waiting on, so the roster has + // to be there — this panel replaces the pages that would otherwise load it. + useEffect(() => { + if (status === "awaiting_key" && !members) loadMembers(teamId).catch(() => {}); + }, [status, members, teamId, loadMembers]); + + // Generic until the handle resolves: a name flashing in from blank reads worse + // than the sentence that never had one. + const owner = ownerHandle(team, members); + + const configs: Record = { + offline: { + icon: "lucide:cloud-off", + title: t("layout.mainPanel.teamVault.offlineTitle"), + body: t("layout.mainPanel.teamVault.offlineBody"), + }, + forbidden: { + icon: "lucide:shield-off", + title: t("layout.mainPanel.teamVault.forbiddenTitle"), + body: t("layout.mainPanel.teamVault.forbiddenBody"), + }, + // Member has joined the team but no vault owner has distributed a key yet + // (issue #41). Distinct from a hard error — a key-holder self-heals this on + // their next sync, so present it as a benign waiting state, not a failure. + awaiting_key: { + icon: "lucide:clock", + title: t("layout.mainPanel.teamVault.waitingForAccessTitle"), + body: owner + ? t("layout.mainPanel.teamVault.waitingForAccessBodyNamed", { owner: `@${owner}` }) + : t("layout.mainPanel.teamVault.waitingForAccessBody"), + }, + payment_required: { + icon: "lucide:credit-card", + title: t("layout.mainPanel.teamVault.paymentRequiredTitle"), + body: isOwner + ? t("layout.mainPanel.teamVault.paymentRequiredBodyOwner") + : t("layout.mainPanel.teamVault.paymentRequiredBodyMember"), + }, + error: { + icon: "lucide:triangle-alert", + title: t("layout.mainPanel.teamVault.errorTitle"), + body: t("layout.mainPanel.teamVault.errorBody"), + }, + }; + + const cfg = configs[status] ?? configs.error; + + const openBilling = () => { + useUIStore.getState().openSettings("account"); + }; + + return ( +
+
+ +
+
+ {cfg.title} + {cfg.body} + {status === "payment_required" && isOwner && ( + + )} + {(!status || status === "error" || status === "awaiting_key") && ( + + )} +
+
+ ); +} diff --git a/src/hooks/useBlockedTeamVault.ts b/src/hooks/useBlockedTeamVault.ts new file mode 100644 index 000000000..8ef9dd94c --- /dev/null +++ b/src/hooks/useBlockedTeamVault.ts @@ -0,0 +1,20 @@ +import { useVaultStore } from "@/stores/vaultStore"; +import { useTeamStore } from "@/stores/teamStore"; +import { isBlockedTeamVaultStatus, useTeamVaultStateStore, type TeamVaultStatus } from "@/stores/teamVaultStateStore"; +import { selectedTeamId } from "@/services/teamVaultFirstAccess"; + +/** + * The team vault on screen that cannot show its contents, or null. Both shells + * read this to decide whether to render `TeamVaultStatePanel` in place of the + * vault's pages, so the selection rule and the blocked statuses live once. + */ +export function useBlockedTeamVault(): { teamId: string; status: TeamVaultStatus } | null { + const selectedVaultIds = useVaultStore((s) => s.selectedVaultIds); + const vaults = useVaultStore((s) => s.vaults); + const teams = useTeamStore((s) => s.teams); + const teamId = selectedTeamId(selectedVaultIds, vaults, teams); + const status = useTeamVaultStateStore((s) => (teamId ? s.statusByTeamId[teamId] : undefined)); + + if (!teamId || !isBlockedTeamVaultStatus(status)) return null; + return { teamId, status: status! }; +} diff --git a/src/services/teamDataManager.firstAccess.test.ts b/src/services/teamDataManager.firstAccess.test.ts index bb65d98ef..cbdea10e6 100644 --- a/src/services/teamDataManager.firstAccess.test.ts +++ b/src/services/teamDataManager.firstAccess.test.ts @@ -12,6 +12,9 @@ const h = vi.hoisted(() => ({ loadRoles: vi.fn(async () => {}), setActiveNav: vi.fn(), setHomeView: vi.fn(), + setTab: vi.fn(), + push: vi.fn(), + isMobileShell: vi.fn(() => false), statusByTeamId: {} as Record, setStatus: vi.fn(), teams: [] as unknown[], @@ -44,6 +47,10 @@ vi.mock("@/stores/uiStore", () => ({ vi.mock("@/stores/vaultStore", () => ({ useVaultStore: { getState: () => ({ selectedVaultIds: h.selectedVaultIds, vaults: h.vaults }) }, })); +vi.mock("@/stores/mobileNavStore", () => ({ + useMobileNavStore: { getState: () => ({ setTab: h.setTab, push: h.push }) }, +})); +vi.mock("@/utils/platform", () => ({ isMobileShell: h.isMobileShell })); import { refreshAwaitingKeyTeams, joinAndLoadTeamVault } from "./teamDataManager"; @@ -52,6 +59,7 @@ beforeEach(() => { // clearAllMocks keeps implementations — a status-flipping stub from an earlier // test would otherwise make the next one's vault load on its own. h.fetchTeamData.mockImplementation(async () => {}); + h.isMobileShell.mockImplementation(() => false); h.statusByTeamId = {}; h.teams = []; h.rolesByTeam = {}; @@ -64,6 +72,18 @@ function connectOnlyTeam(teamId: string) { h.rolesByTeam = { [teamId]: [{ id: "r1", name: "connect-only", permissions: CONNECT_ONLY }] }; } +function secretsReaderTeam(teamId: string) { + h.teams = [{ id: teamId, role_ids: ["r1"] }]; + h.rolesByTeam = { [teamId]: [{ id: "r1", name: "reader", permissions: PERM_BITS.VIEW_SECRETS }] }; +} + +/** A team whose vault unlocks on the next fetch, selected and on screen. */ +function unlockingOnScreen(teamId: string) { + h.selectedVaultIds = [teamId]; + h.statusByTeamId = { [teamId]: "awaiting_key" }; + h.fetchTeamData.mockImplementation(async () => { h.statusByTeamId[teamId] = "loaded"; }); +} + test("only teams still waiting on a key are re-read", async () => { h.statusByTeamId = { t1: "awaiting_key", t2: "loaded", t3: "offline", t4: "awaiting_key" }; await refreshAwaitingKeyTeams(); @@ -109,6 +129,48 @@ test("a vault still waiting on its key does not steer the nav", async () => { expect(h.setActiveNav).not.toHaveBeenCalled(); }); +test("on mobile a connect-only member lands on the hosts tab", async () => { + h.isMobileShell.mockImplementation(() => true); + connectOnlyTeam("t1"); + unlockingOnScreen("t1"); + + await refreshAwaitingKeyTeams(); + + expect(h.setTab).toHaveBeenCalledWith("hosts"); + expect(h.push).not.toHaveBeenCalled(); +}); + +test("on mobile a secrets reader is pushed to the keychain page under More", async () => { + h.isMobileShell.mockImplementation(() => true); + secretsReaderTeam("t1"); + unlockingOnScreen("t1"); + + await refreshAwaitingKeyTeams(); + + expect(h.setTab).toHaveBeenCalledWith("more"); + expect(h.push).toHaveBeenCalledWith({ kind: "more-page", page: "keychain" }); +}); + +test("on mobile the desktop nav is left alone", async () => { + h.isMobileShell.mockImplementation(() => true); + connectOnlyTeam("t1"); + unlockingOnScreen("t1"); + + await refreshAwaitingKeyTeams(); + + expect(h.setActiveNav).not.toHaveBeenCalled(); +}); + +test("on desktop the mobile nav is left alone", async () => { + connectOnlyTeam("t1"); + unlockingOnScreen("t1"); + + await refreshAwaitingKeyTeams(); + + expect(h.setActiveNav).toHaveBeenCalledWith("hosts"); + expect(h.setTab).not.toHaveBeenCalled(); +}); + test("joining a vault that loads lands on the role's surface", async () => { connectOnlyTeam("t1"); h.fetchTeamData.mockImplementation(async () => { h.statusByTeamId.t1 = "loaded"; }); diff --git a/src/services/teamDataManager.ts b/src/services/teamDataManager.ts index 78861bdf5..202528d22 100644 --- a/src/services/teamDataManager.ts +++ b/src/services/teamDataManager.ts @@ -9,7 +9,9 @@ import { useTeamStore } from "@/stores/teamStore"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; import { useUIStore } from "@/stores/uiStore"; import { useVaultStore } from "@/stores/vaultStore"; -import { firstViewNav, selectedTeamId } from "@/services/teamVaultFirstAccess"; +import { useMobileNavStore } from "@/stores/mobileNavStore"; +import { firstViewNav, mobileFirstViewTarget, selectedTeamId } from "@/services/teamVaultFirstAccess"; +import { isMobileShell } from "@/utils/platform"; import { effectivePermissions } from "@/services/permissions"; import { useConnectionStore } from "@/stores/connectionStore"; import { useIdentityStore } from "@/stores/identityStore"; @@ -86,13 +88,24 @@ export async function joinAndLoadTeamVault(teamId: string): Promise { * * A no-op until the roles are known: guessing a landing surface from an * unresolved role is worse than leaving the user where they were. + * + * The two shells navigate through different stores, so each gets the write it + * understands and neither touches the other's: `activeNav`/`homeView` mean + * nothing to MobileShell, and a mobile tab means nothing to MainPanel. */ function applyFirstViewNav(teamId: string): void { const { teams, rolesByTeam } = useTeamStore.getState(); const team = teams.find((t) => t.id === teamId); const roles = rolesByTeam[teamId]; if (!team || !roles || roles.length === 0) return; - useUIStore.getState().setActiveNav(firstViewNav(effectivePermissions({ role_ids: team.role_ids }, roles))); + const nav = firstViewNav(effectivePermissions({ role_ids: team.role_ids }, roles)); + if (isMobileShell()) { + const { tab, screen } = mobileFirstViewTarget(nav); + useMobileNavStore.getState().setTab(tab); + if (screen) useMobileNavStore.getState().push(screen); + return; + } + useUIStore.getState().setActiveNav(nav); useUIStore.getState().setHomeView(false); } diff --git a/src/services/teamVaultFirstAccess.test.ts b/src/services/teamVaultFirstAccess.test.ts index dc508d3ee..596297dee 100644 --- a/src/services/teamVaultFirstAccess.test.ts +++ b/src/services/teamVaultFirstAccess.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { ownerHandle, firstViewNav, selectedTeamId } from "./teamVaultFirstAccess.ts"; +import { ownerHandle, firstViewNav, mobileFirstViewTarget, selectedTeamId } from "./teamVaultFirstAccess.ts"; import { PERM_BITS } from "./permissions.ts"; import type { Team, TeamMember } from "@/services/teamService"; import type { Vault } from "@/stores/vaultStore"; @@ -39,6 +39,25 @@ test("a role without CONNECT lands where it can read", () => { expect(firstViewNav(PERM_BITS.MANAGE_MEMBERS)).toBe("members"); }); +test("the mobile landing for a connect-only member is the hosts tab, no pushed page", () => { + expect(mobileFirstViewTarget("hosts")).toEqual({ tab: "hosts", screen: null }); +}); + +test("mobile landings that live under More push the page they mean", () => { + expect(mobileFirstViewTarget("keychain")).toEqual({ + tab: "more", + screen: { kind: "more-page", page: "keychain" }, + }); + expect(mobileFirstViewTarget("members")).toEqual({ + tab: "more", + screen: { kind: "more-page", page: "members" }, + }); +}); + +test("a nav item with no mobile destination falls back to the hosts tab", () => { + expect(mobileFirstViewTarget("terminal")).toEqual({ tab: "hosts", screen: null }); +}); + test("a team is selected directly or through a vault linked to it", () => { const vaults: Vault[] = [{ id: "v1", name: "Ops", teamId: "t1" }, { id: "v2", name: "Local" }]; expect(selectedTeamId(["t1"], vaults, [team("u9")])).toBe("t1"); diff --git a/src/services/teamVaultFirstAccess.ts b/src/services/teamVaultFirstAccess.ts index a2ffa1323..84d452372 100644 --- a/src/services/teamVaultFirstAccess.ts +++ b/src/services/teamVaultFirstAccess.ts @@ -9,6 +9,7 @@ import type { Team, TeamMember } from "@/services/teamService"; import type { Vault } from "@/stores/vaultStore"; import type { NavItem } from "@/stores/uiStore"; +import type { MobileScreen, MobileTab } from "@/stores/mobileNavCore"; import { PERM_BITS } from "@/services/permissions"; /** @@ -37,6 +38,22 @@ export function firstViewNav(permissions: number): NavItem { return "hosts"; } +/** + * The mobile destination for a `firstViewNav` result. The mobile shell has no + * single nav axis: `hosts` is a tab, while the keychain and members live as + * pushed pages under More. Kept as a mapping off the desktop nav item so the + * permission rule stays in `firstViewNav` alone. + */ +export function mobileFirstViewTarget(nav: NavItem): { + tab: MobileTab; + screen: MobileScreen | null; +} { + if (nav === "keychain" || nav === "members") { + return { tab: "more", screen: { kind: "more-page", page: nav } }; + } + return { tab: "hosts", screen: null }; +} + /** * The team whose vault is on screen, or null when the selection is not a single * team vault. A team can be selected either as a standalone team or through a diff --git a/src/stores/teamVaultStateStore.test.ts b/src/stores/teamVaultStateStore.test.ts new file mode 100644 index 000000000..994ef79b5 --- /dev/null +++ b/src/stores/teamVaultStateStore.test.ts @@ -0,0 +1,20 @@ +import { test, expect } from "vitest"; +import { isBlockedTeamVaultStatus } from "./teamVaultStateStore.ts"; + +test("a team vault that cannot show its contents is blocked", () => { + expect(isBlockedTeamVaultStatus("awaiting_key")).toBe(true); + expect(isBlockedTeamVaultStatus("offline")).toBe(true); + expect(isBlockedTeamVaultStatus("forbidden")).toBe(true); + expect(isBlockedTeamVaultStatus("payment_required")).toBe(true); + expect(isBlockedTeamVaultStatus("error")).toBe(true); +}); + +test("a vault that is loading or loaded is not blocked", () => { + expect(isBlockedTeamVaultStatus("idle")).toBe(false); + expect(isBlockedTeamVaultStatus("loading")).toBe(false); + expect(isBlockedTeamVaultStatus("loaded")).toBe(false); +}); + +test("a team with no status yet is not blocked", () => { + expect(isBlockedTeamVaultStatus(undefined)).toBe(false); +}); diff --git a/src/stores/teamVaultStateStore.ts b/src/stores/teamVaultStateStore.ts index 7f48ac765..f42b96110 100644 --- a/src/stores/teamVaultStateStore.ts +++ b/src/stores/teamVaultStateStore.ts @@ -10,6 +10,23 @@ export type TeamVaultStatus = | "awaiting_key" | "error"; +/** + * Statuses where the vault's pages have nothing truthful to render, so a shell + * shows the explanatory panel instead. `loading` is excluded on purpose — it + * resolves on its own and flashing a panel through it reads as an error. + */ +const BLOCKED_STATUSES = new Set([ + "offline", + "forbidden", + "payment_required", + "awaiting_key", + "error", +]); + +export function isBlockedTeamVaultStatus(status: TeamVaultStatus | undefined | null): boolean { + return !!status && BLOCKED_STATUSES.has(status); +} + interface TeamVaultStateStore { statusByTeamId: Record; errorByTeamId: Record;