Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 5 additions & 133 deletions src/components/layout/MainPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, { icon: string; title: string; body: string }> = {
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 (
<div className="flex-1 flex flex-col items-center justify-center gap-4 bg-(--t-bg-base)">
<div
className="flex items-center justify-center rounded-3xl w-[5.333rem] h-[5.333rem] text-(--t-text-dim)"
style={{
background: "linear-gradient(135deg, var(--t-bg-elevated) 0%, var(--t-bg-card) 100%)",
border: "1px solid var(--t-border)",
}}
>
<Icon icon={cfg.icon} width={36} />
</div>
<div className="flex flex-col items-center gap-1.5 text-center max-w-xs">
<span className="text-base font-semibold text-(--t-text-primary)">{cfg.title}</span>
<span className="text-sm text-(--t-text-dim)">{cfg.body}</span>
{status === "payment_required" && isOwner && (
<button
onClick={openBilling}
className="mt-2 text-sm px-3 py-1.5 rounded-lg"
style={{ background: "var(--t-accent)", color: "#fff" }}
>
{t("layout.mainPanel.manageSubscription")}
</button>
)}
{(!status || status === "error" || status === "awaiting_key") && (
<button
onClick={() => fetchTeamData(teamId).catch(() => {})}
className="mt-2 text-sm px-3 py-1.5 rounded-lg"
style={{ background: "var(--t-bg-elevated)", color: "var(--t-text-primary)", border: "1px solid var(--t-border)" }}
>
{t("layout.mainPanel.tryAgain")}
</button>
)}
</div>
</div>
);
}

const PLACEHOLDER_PAGES: Record<string, { icon: string; title: string; description: string }> = {};

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);
Expand All @@ -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
Expand Down Expand Up @@ -240,7 +112,7 @@ export default function MainPanel() {
</div>
) : showTeamVaultState ? (
<div className="absolute inset-0 flex flex-col overflow-hidden">
<TeamVaultState status={teamVaultStatus!} teamId={selectedTeamId!} />
<TeamVaultStatePanel status={blockedTeamVault!.status} teamId={blockedTeamVault!.teamId} />
</div>
) : sessions.length === 0 && !showSplitWorkspace ? (
<div className="absolute inset-0 flex flex-col overflow-hidden">
Expand Down
85 changes: 85 additions & 0 deletions src/components/mobile/MobileShell.teamVault.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>) => (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: () => <div>hosts-screen</div> }));
vi.mock("./screens/MobileSnippetsScreen", () => ({ default: () => <div>snippets-screen</div> }));
vi.mock("./screens/MobileTerminalScreen", () => ({ default: () => <div>terminal-screen</div> }));
vi.mock("./panels/MobileSftpScreen", () => ({ default: () => <div>sftp-screen</div> }));

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(<MobileShell />);

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(<MobileShell />);

// 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(<MobileShell />);

// 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(<MobileShell />);

expect(screen.getByText("hosts-screen")).toBeTruthy();
expect(screen.queryByText("layout.mainPanel.teamVault.waitingForAccessTitle")).toBeNull();
});
26 changes: 24 additions & 2 deletions src/components/mobile/MobileShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -107,7 +116,10 @@ export default function MobileShell() {
{/* Always-mounted sessions; visibility toggled so xterm survives tab switches */}
<MobileSessionLayer visible={terminalVisible && hasSessions} />
{/* 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 && (
<div className="absolute inset-0 flex flex-col bg-(--t-bg-base)" style={{ overflow: "clip" }}>
{tab === "hosts" && !top && <MobileHostsScreen />}
{tab === "snippets" && !top && <MobileSnippetsScreen />}
Expand Down Expand Up @@ -141,6 +153,16 @@ export default function MobileShell() {
{resolvedPanel && renderMobileScreen(resolvedPanel.screenKind, resolvedPanel.props)}
{top?.kind === "panel-sftp" && <MobileSftpScreen presetConnectionId={top.connectionId} />}
{top?.kind === "account" && <MobileAccountPage />}
{/* 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 && (
<div className="absolute inset-0 z-40 flex flex-col bg-(--t-bg-base)">
<MobileHeader />
<TeamVaultStatePanel status={blockedTeamVault.status} teamId={blockedTeamVault.teamId} />
</div>
)}
</div>
{/* 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. */}
Expand Down
52 changes: 52 additions & 0 deletions src/components/team/TeamVaultStatePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>) => (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(<TeamVaultStatePanel status="awaiting_key" teamId="t1" />);

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(<TeamVaultStatePanel status="awaiting_key" teamId="t1" />);

expect(screen.getByText("layout.mainPanel.teamVault.waitingForAccessBody")).toBeTruthy();
});

test("an unrecognised status falls back to the generic error", () => {
render(<TeamVaultStatePanel status="banana" teamId="t1" />);

expect(screen.getByText("layout.mainPanel.teamVault.errorTitle")).toBeTruthy();
});
Loading