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
31 changes: 18 additions & 13 deletions src/components/layout/MainPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/sessionStore";
import { sessionClosed } from "@/stores/reconnectBackoff";
Expand All @@ -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";
Expand Down Expand Up @@ -63,13 +65,25 @@ 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) => {
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",
Expand All @@ -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",
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"forbiddenBody": "У вас больше нет доступа к этому командному хранилищу. Обратитесь к владельцу команды.",
"waitingForAccessTitle": "Ожидание доступа",
"waitingForAccessBody": "Владелец командного хранилища должен предоставить вам доступ. Это произойдёт автоматически, когда владелец в следующий раз будет в сети.",
"waitingForAccessBodyNamed": "Ожидание, пока {{owner}} предоставит доступ — достаточно один раз выйти в сеть.",
"paymentRequiredTitle": "Командное хранилище недоступно",
"paymentRequiredBodyOwner": "Срок действия вашей подписки истёк. Для командных хранилищ требуется активная подписка.",
"paymentRequiredBodyMember": "Срок действия подписки владельца истёк. Для командных хранилищ требуется активная подписка.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"forbiddenBody": "您不再有权访问此团队保险库。请联系团队所有者。",
"waitingForAccessTitle": "等待访问权限",
"waitingForAccessBody": "需要团队保险库所有者授予您访问权限。当所有者下次在线时将自动完成。",
"waitingForAccessBodyNamed": "等待 {{owner}} 授予访问权限——只需其上线一次。",
"paymentRequiredTitle": "团队保险库不可用",
"paymentRequiredBodyOwner": "您的订阅已结束。团队保险库需要有效的套餐。",
"paymentRequiredBodyMember": "所有者的订阅已结束。团队保险库需要有效的套餐。",
Expand Down
13 changes: 2 additions & 11 deletions src/services/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const PERM_BITS: Record<Permission, number> = {
};

/** 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);
Expand Down Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions src/services/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
128 changes: 128 additions & 0 deletions src/services/teamDataManager.firstAccess.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
setStatus: vi.fn(),
teams: [] as unknown[],
rolesByTeam: {} as Record<string, unknown[]>,
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);
60 changes: 60 additions & 0 deletions src/services/teamDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -70,6 +74,62 @@ export async function joinAndLoadTeamVault(teamId: string): Promise<void> {
await new Promise<void>((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<void> {
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);
}
}

/**
Expand Down
48 changes: 48 additions & 0 deletions src/services/teamVaultFirstAccess.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading