From b2865dbd20da65147d70d12f905467b18ab03769 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 26 Aug 2026 00:16:25 +0000 Subject: [PATCH] fix(team): unlock a joined team vault when its key lands, and name the owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the parts of #70 that survived review. The keyless mechanism and the benign waiting panel shipped with #41; what was missing was that the wait never ended on its own, and that the copy could not say who it was waiting for. - `put_vault_keys` notifies every key recipient with `membership_changed`, but the joiner is already a member by then, so the membership delta is zero, `onTeamAdded` never fires, and nothing re-read the key that had just landed. The honest waiting state was therefore also a permanent one until the user hit Retry or restarted. Any team still in `awaiting_key` is now re-fetched on that event. Foreground fetch on purpose: `{ background: true }` suppresses every status write, including the "loaded" a team with no blob yet reaches, which unlocks the vault in memory while the panel keeps saying "waiting". - The waiting copy names the owner — "Waiting for @handle to grant access — they need to come online once" — and falls back to the existing generic sentence while the roster is unresolved rather than flashing a blank name. The panel loads the roster itself, since it stands in for the pages that otherwise would. - A member landing in a team vault for the first time gets the surface their role can use, chosen by permission bits rather than role name: a connect-only invitee lands on connections, not a keychain of redacted rows. #70 also asked for a welcome card on first access. Deliberately not built: the waiting panel already promises this exact outcome, so the vault opening is the promise being kept rather than an unexplained event, and every fact such a card would carry — vault name, sharer, role, contents — is already in the vault header, the Members tab and the list behind it. --- src/components/layout/MainPanel.tsx | 31 +++-- src/i18n/locales/en/layout.json | 1 + src/i18n/locales/fr/layout.json | 1 + src/i18n/locales/ru/layout.json | 1 + src/i18n/locales/zh/layout.json | 1 + src/services/permissions.ts | 13 +- src/services/sync.ts | 6 + .../teamDataManager.firstAccess.test.ts | 128 ++++++++++++++++++ src/services/teamDataManager.ts | 60 ++++++++ src/services/teamVaultFirstAccess.test.ts | 48 +++++++ src/services/teamVaultFirstAccess.ts | 55 ++++++++ 11 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 src/services/teamDataManager.firstAccess.test.ts create mode 100644 src/services/teamVaultFirstAccess.test.ts create mode 100644 src/services/teamVaultFirstAccess.ts diff --git a/src/components/layout/MainPanel.tsx b/src/components/layout/MainPanel.tsx index 14766dff0..74966791c 100644 --- a/src/components/layout/MainPanel.tsx +++ b/src/components/layout/MainPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useSessionStore } from "@/stores/sessionStore"; import { sessionClosed } from "@/stores/reconnectBackoff"; @@ -6,6 +7,7 @@ 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 MultiplayerTerminalView from "@/components/terminal/MultiplayerTerminalView"; import { MultiplayerBar } from "@/components/terminal/MultiplayerBar"; import { HostAwareTerminalView, SessionConnectionOverlay } from "@/components/terminal/SessionView"; @@ -63,6 +65,8 @@ function TeamVaultState({ 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) => { @@ -70,6 +74,16 @@ function TeamVaultState({ 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", @@ -87,7 +101,9 @@ function TeamVaultState({ awaiting_key: { icon: "lucide:clock", title: t("layout.mainPanel.teamVault.waitingForAccessTitle"), - body: t("layout.mainPanel.teamVault.waitingForAccessBody"), + body: owner + ? t("layout.mainPanel.teamVault.waitingForAccessBodyNamed", { owner: `@${owner}` }) + : t("layout.mainPanel.teamVault.waitingForAccessBody"), }, payment_required: { icon: "lucide:credit-card", @@ -153,18 +169,7 @@ function useSelectedTeamId(): string | null { const vaults = useVaultStore((s) => s.vaults); const teams = useTeamStore((s) => s.teams); - if (selectedVaultIds.length !== 1) return null; - const vid = selectedVaultIds[0]; - - // Standalone team - const team = teams.find((t) => t.id === vid); - if (team) return team.id; - - // Vault linked to a team - const vault = vaults.find((v) => v.id === vid); - if (vault?.teamId) return vault.teamId; - - return null; + return selectedTeamId(selectedVaultIds, vaults, teams); } export default function MainPanel() { diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index ea8895f2b..7dbfe1290 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -73,6 +73,7 @@ "forbiddenBody": "You no longer have access to this team vault. Contact the team owner.", "waitingForAccessTitle": "Waiting for access", "waitingForAccessBody": "A team vault owner needs to grant you access. This happens automatically the next time an owner is online.", + "waitingForAccessBodyNamed": "Waiting for {{owner}} to grant access — they need to come online once.", "paymentRequiredTitle": "Team vault unavailable", "paymentRequiredBodyOwner": "Your subscription has ended. Team vaults require an active plan.", "paymentRequiredBodyMember": "The owner's subscription has ended. Team vaults require an active plan.", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index d9340f403..8ab908987 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -73,6 +73,7 @@ "forbiddenBody": "Vous n'avez plus accès à ce coffre d'équipe. Contactez le propriétaire de l'équipe.", "waitingForAccessTitle": "En attente d'accès", "waitingForAccessBody": "Un propriétaire du coffre d'équipe doit vous accorder l'accès. Cela se fait automatiquement à la prochaine connexion d'un propriétaire.", + "waitingForAccessBodyNamed": "En attente que {{owner}} vous accorde l'accès — une seule connexion de sa part suffit.", "paymentRequiredTitle": "Coffre d'équipe indisponible", "paymentRequiredBodyOwner": "Votre abonnement a expiré. Les coffres d'équipe nécessitent un abonnement actif.", "paymentRequiredBodyMember": "L'abonnement du propriétaire a expiré. Les coffres d'équipe nécessitent un abonnement actif.", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index 6adef5b30..1f065f3da 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -73,6 +73,7 @@ "forbiddenBody": "У вас больше нет доступа к этому командному хранилищу. Обратитесь к владельцу команды.", "waitingForAccessTitle": "Ожидание доступа", "waitingForAccessBody": "Владелец командного хранилища должен предоставить вам доступ. Это произойдёт автоматически, когда владелец в следующий раз будет в сети.", + "waitingForAccessBodyNamed": "Ожидание, пока {{owner}} предоставит доступ — достаточно один раз выйти в сеть.", "paymentRequiredTitle": "Командное хранилище недоступно", "paymentRequiredBodyOwner": "Срок действия вашей подписки истёк. Для командных хранилищ требуется активная подписка.", "paymentRequiredBodyMember": "Срок действия подписки владельца истёк. Для командных хранилищ требуется активная подписка.", diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json index bc23867cd..627071239 100644 --- a/src/i18n/locales/zh/layout.json +++ b/src/i18n/locales/zh/layout.json @@ -73,6 +73,7 @@ "forbiddenBody": "您不再有权访问此团队保险库。请联系团队所有者。", "waitingForAccessTitle": "等待访问权限", "waitingForAccessBody": "需要团队保险库所有者授予您访问权限。当所有者下次在线时将自动完成。", + "waitingForAccessBodyNamed": "等待 {{owner}} 授予访问权限——只需其上线一次。", "paymentRequiredTitle": "团队保险库不可用", "paymentRequiredBodyOwner": "您的订阅已结束。团队保险库需要有效的套餐。", "paymentRequiredBodyMember": "所有者的订阅已结束。团队保险库需要有效的套餐。", diff --git a/src/services/permissions.ts b/src/services/permissions.ts index 100cf2ac2..896324692 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -42,7 +42,7 @@ export const PERM_BITS: Record = { }; /** OR together all permission bits for a member's assigned roles. */ -export function effectivePermissions(member: TeamMember, roles: TeamRole[]): number { +export function effectivePermissions(member: { role_ids: string[] }, roles: TeamRole[]): number { return member.role_ids.reduce((acc, rid) => { const role = roles.find((r) => r.id === rid); return acc | (role?.permissions ?? 0); @@ -95,14 +95,5 @@ export function resolveCan( const myTeam = snapshot.teams.find((t) => t.id === teamId); if (!myTeam || roles.length === 0) return false; - const fakeMember: TeamMember = { - team_id: teamId, - user_id: snapshot.myUserId, - handle: "", - public_key: "", - invited_by_display_name: null, - joined_at: "", - role_ids: myTeam.role_ids, - }; - return (effectivePermissions(fakeMember, roles) & PERM_BITS[permission]) !== 0; + return (effectivePermissions({ role_ids: myTeam.role_ids }, roles) & PERM_BITS[permission]) !== 0; } diff --git a/src/services/sync.ts b/src/services/sync.ts index c853ff6fc..13fef13fb 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -865,6 +865,12 @@ async function handleRealtimeEvent(eventData: string, myDeviceId: string): Promi } else if (eventData.startsWith("pending_invitations_changed:")) { useTeamStore.getState().loadMyPendingInvitations().catch(() => {}); } else if (eventData === "membership_changed") { + // Also fired at every recipient of a freshly wrapped vault key. Those users + // are already members, so the delta below is zero and nothing would re-read + // the key that just landed (issue #70). + const { refreshAwaitingKeyTeams } = await import("@/services/teamDataManager"); + refreshAwaitingKeyTeams().catch(() => {}); + handleMembershipChangedEvent({ getTeamIds: () => useTeamStore.getState().teams.map((t) => t.id), loadTeams: () => useTeamStore.getState().loadTeams(), diff --git a/src/services/teamDataManager.firstAccess.test.ts b/src/services/teamDataManager.firstAccess.test.ts new file mode 100644 index 000000000..bb65d98ef --- /dev/null +++ b/src/services/teamDataManager.firstAccess.test.ts @@ -0,0 +1,128 @@ +import { test, expect, vi, beforeEach } from "vitest"; +import { PERM_BITS } from "./permissions.ts"; + +const CONNECT_ONLY = PERM_BITS.CONNECT | PERM_BITS.START_TERMINAL_SESSION + | PERM_BITS.JOIN_TERMINAL_SESSION | PERM_BITS.VIEW_TERMINAL_SESSIONS; + +const h = vi.hoisted(() => ({ + fetchTeamData: vi.fn(async (_teamId: string) => {}), + clearTeamKeyCache: vi.fn(), + reconcileTeamVaultKeys: vi.fn(async () => {}), + loadMembers: vi.fn(async () => {}), + loadRoles: vi.fn(async () => {}), + setActiveNav: vi.fn(), + setHomeView: vi.fn(), + statusByTeamId: {} as Record, + setStatus: vi.fn(), + teams: [] as unknown[], + rolesByTeam: {} as Record, + selectedVaultIds: [] as string[], + vaults: [] as unknown[], +})); + +vi.mock("@/services/teamVaultSync", () => ({ + fetchTeamData: h.fetchTeamData, + clearTeamKeyCache: h.clearTeamKeyCache, + reconcileTeamVaultKeys: h.reconcileTeamVaultKeys, +})); +vi.mock("@/stores/teamStore", () => ({ + useTeamStore: { + getState: () => ({ + teams: h.teams, rolesByTeam: h.rolesByTeam, + loadMembers: h.loadMembers, loadRoles: h.loadRoles, + }), + }, +})); +vi.mock("@/stores/teamVaultStateStore", () => ({ + useTeamVaultStateStore: { + getState: () => ({ statusByTeamId: h.statusByTeamId, setStatus: h.setStatus }), + }, +})); +vi.mock("@/stores/uiStore", () => ({ + useUIStore: { getState: () => ({ setActiveNav: h.setActiveNav, setHomeView: h.setHomeView }) }, +})); +vi.mock("@/stores/vaultStore", () => ({ + useVaultStore: { getState: () => ({ selectedVaultIds: h.selectedVaultIds, vaults: h.vaults }) }, +})); + +import { refreshAwaitingKeyTeams, joinAndLoadTeamVault } from "./teamDataManager"; + +beforeEach(() => { + vi.clearAllMocks(); + // 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.statusByTeamId = {}; + h.teams = []; + h.rolesByTeam = {}; + h.selectedVaultIds = []; + h.vaults = []; +}); + +function connectOnlyTeam(teamId: string) { + h.teams = [{ id: teamId, role_ids: ["r1"] }]; + h.rolesByTeam = { [teamId]: [{ id: "r1", name: "connect-only", permissions: CONNECT_ONLY }] }; +} + +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(); + expect(h.fetchTeamData.mock.calls.map((c) => c[0])).toEqual(["t1", "t4"]); +}); + +test("the re-read is a foreground fetch, so the vault can reach loaded", async () => { + h.statusByTeamId = { t1: "awaiting_key" }; + await refreshAwaitingKeyTeams(); + expect(h.fetchTeamData).toHaveBeenCalledWith("t1"); +}); + +test("a vault that unlocks under the waiting panel lands on the role's surface", async () => { + connectOnlyTeam("t1"); + h.selectedVaultIds = ["t1"]; + h.statusByTeamId = { t1: "awaiting_key" }; + h.fetchTeamData.mockImplementation(async () => { h.statusByTeamId.t1 = "loaded"; }); + + await refreshAwaitingKeyTeams(); + + expect(h.setActiveNav).toHaveBeenCalledWith("hosts"); + expect(h.setHomeView).toHaveBeenCalledWith(false); +}); + +test("a vault that unlocks off screen never steers the nav", async () => { + connectOnlyTeam("t1"); + h.selectedVaultIds = ["other"]; + h.statusByTeamId = { t1: "awaiting_key" }; + h.fetchTeamData.mockImplementation(async () => { h.statusByTeamId.t1 = "loaded"; }); + + await refreshAwaitingKeyTeams(); + + expect(h.setActiveNav).not.toHaveBeenCalled(); +}); + +test("a vault still waiting on its key does not steer the nav", async () => { + connectOnlyTeam("t1"); + h.selectedVaultIds = ["t1"]; + h.statusByTeamId = { t1: "awaiting_key" }; + + await refreshAwaitingKeyTeams(); + + expect(h.setActiveNav).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"; }); + + await joinAndLoadTeamVault("t1"); + + expect(h.setActiveNav).toHaveBeenCalledWith("hosts"); +}); + +test("joining a vault that stays keyless leaves the nav alone", async () => { + connectOnlyTeam("t1"); + h.fetchTeamData.mockImplementation(async () => { h.statusByTeamId.t1 = "awaiting_key"; }); + + await joinAndLoadTeamVault("t1"); + + expect(h.setActiveNav).not.toHaveBeenCalled(); +}, 20000); diff --git a/src/services/teamDataManager.ts b/src/services/teamDataManager.ts index f0ddae43c..78861bdf5 100644 --- a/src/services/teamDataManager.ts +++ b/src/services/teamDataManager.ts @@ -7,6 +7,10 @@ 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 { effectivePermissions } from "@/services/permissions"; import { useConnectionStore } from "@/stores/connectionStore"; import { useIdentityStore } from "@/stores/identityStore"; import { useKeyStore } from "@/stores/keyStore"; @@ -70,6 +74,62 @@ export async function joinAndLoadTeamVault(teamId: string): Promise { await new Promise((r) => setTimeout(r, 1500 * (attempt + 1))); } } + if (useTeamVaultStateStore.getState().statusByTeamId[teamId] === "loaded") { + applyFirstViewNav(teamId); + } +} + +/** + * Land a member on a surface their role can use, the first time a team vault + * opens for them (issue #70). A connect-only invitee holds CONNECT without + * VIEW_SECRETS, so anything keychain-shaped is a wall of redacted rows. + * + * 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. + */ +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))); + useUIStore.getState().setHomeView(false); +} + +/** + * Re-fetch every team vault currently stuck in `awaiting_key`. + * + * The server notifies each recipient of a wrapped key with `membership_changed` + * (`put_vault_keys` in server/src/routes/team_sync.rs), but the joiner is + * already in the team by then, so the membership delta is zero and + * `onTeamAdded` never fires — the key lands and nothing re-reads it. Without + * this the honest waiting state is also a permanent one until the user hits + * Retry or restarts. + * + * Deliberately a foreground fetch: `{ background: true }` suppresses every + * status write, including the "loaded" that a team with no blob yet reaches, so + * the vault would unlock in memory while the panel kept saying "waiting". + */ +export async function refreshAwaitingKeyTeams(): Promise { + const { statusByTeamId } = useTeamVaultStateStore.getState(); + const waiting = Object.entries(statusByTeamId) + .filter(([, status]) => status === "awaiting_key") + .map(([teamId]) => teamId); + await Promise.allSettled(waiting.map((teamId) => fetchTeamData(teamId))); + + // The waiting panel is on screen for exactly one team, and it has just been + // replaced by that vault's pages — pick the ones the role can use. Any other + // team is left alone: steering the nav from a background event would yank the + // user out of whatever they were doing. + const { selectedVaultIds, vaults } = useVaultStore.getState(); + const onScreen = selectedTeamId(selectedVaultIds, vaults, useTeamStore.getState().teams); + if ( + onScreen && + waiting.includes(onScreen) && + useTeamVaultStateStore.getState().statusByTeamId[onScreen] === "loaded" + ) { + applyFirstViewNav(onScreen); + } } /** diff --git a/src/services/teamVaultFirstAccess.test.ts b/src/services/teamVaultFirstAccess.test.ts new file mode 100644 index 000000000..dc508d3ee --- /dev/null +++ b/src/services/teamVaultFirstAccess.test.ts @@ -0,0 +1,48 @@ +import { test, expect } from "vitest"; +import { ownerHandle, firstViewNav, selectedTeamId } from "./teamVaultFirstAccess.ts"; +import { PERM_BITS } from "./permissions.ts"; +import type { Team, TeamMember } from "@/services/teamService"; +import type { Vault } from "@/stores/vaultStore"; + +const CONNECT_ONLY = PERM_BITS.CONNECT | PERM_BITS.START_TERMINAL_SESSION + | PERM_BITS.JOIN_TERMINAL_SESSION | PERM_BITS.VIEW_TERMINAL_SESSIONS; + +function team(owner_id: string, id = "t1"): Team { + return { id, name: "Ops", owner_id, 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: [], + }; +} + +test("ownerHandle finds the owner's handle", () => { + expect(ownerHandle(team("u9"), [member("u1", "alice"), member("u9", "bob")])).toBe("bob"); +}); + +test("ownerHandle is null when the roster has not loaded", () => { + expect(ownerHandle(team("u9"), undefined)).toBeNull(); +}); + +test("ownerHandle is null when the server omits handles", () => { + expect(ownerHandle(team("u9"), [member("u9")])).toBeNull(); + expect(ownerHandle(team("u9"), [member("u9", " ")])).toBeNull(); +}); + +test("connect-only lands on connections, never the keychain", () => { + expect(firstViewNav(CONNECT_ONLY)).toBe("hosts"); +}); + +test("a role without CONNECT lands where it can read", () => { + expect(firstViewNav(PERM_BITS.VIEW_SECRETS)).toBe("keychain"); + expect(firstViewNav(PERM_BITS.MANAGE_MEMBERS)).toBe("members"); +}); + +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"); + expect(selectedTeamId(["v1"], vaults, [])).toBe("t1"); + expect(selectedTeamId(["v2"], vaults, [])).toBeNull(); + expect(selectedTeamId(["v1", "v2"], vaults, [])).toBeNull(); +}); diff --git a/src/services/teamVaultFirstAccess.ts b/src/services/teamVaultFirstAccess.ts new file mode 100644 index 000000000..a2ffa1323 --- /dev/null +++ b/src/services/teamVaultFirstAccess.ts @@ -0,0 +1,55 @@ +/** + * First-access behaviour for a team vault (issue #70). + * + * Pure helpers only — the owner name the waiting panel shows, the surface a + * new member should land on, and the rule that maps the vault selection to a + * team. Kept out of the components so all three are testable without React. + */ + +import type { Team, TeamMember } from "@/services/teamService"; +import type { Vault } from "@/stores/vaultStore"; +import type { NavItem } from "@/stores/uiStore"; +import { PERM_BITS } from "@/services/permissions"; + +/** + * Handle of the member who owns the team, or null when the roster has not + * loaded yet or the server predates handles (migration 035). Callers fall back + * to the generic copy — a blank name flashing into the sentence reads as a bug. + */ +export function ownerHandle(team: Team | undefined, members: TeamMember[] | undefined): string | null { + if (!team || !members) return null; + const handle = members.find((m) => m.user_id === team.owner_id)?.handle?.trim(); + return handle ? handle : null; +} + +/** + * Where a first-time member should land in a team vault they just gained. + * + * A connect-only invitee holds CONNECT without VIEW_SECRETS: the connection + * list is the only surface that does anything for them, and the keychain is a + * wall of redacted rows. Custom roles are covered by the same bit checks rather + * than by role name. + */ +export function firstViewNav(permissions: number): NavItem { + if (permissions & PERM_BITS.CONNECT) return "hosts"; + if (permissions & PERM_BITS.VIEW_SECRETS) return "keychain"; + if (permissions & PERM_BITS.MANAGE_MEMBERS) return "members"; + return "hosts"; +} + +/** + * 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 + * local vault linked to it. + */ +export function selectedTeamId( + selectedVaultIds: string[], + vaults: Vault[], + teams: Team[], +): string | null { + if (selectedVaultIds.length !== 1) return null; + const selected = selectedVaultIds[0]; + const team = teams.find((t) => t.id === selected); + if (team) return team.id; + return vaults.find((v) => v.id === selected)?.teamId ?? null; +}