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
8 changes: 8 additions & 0 deletions src/components/logs/AuditEventRow.memberName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ test("rename label reads differently when the name was cleared", () => {
expect(rename({ old: null, new: "Jan" })).toBe('logs.eventLabels.memberRenamed:{"name":"bob","to":"Jan"}');
expect(rename({ old: "Jan", new: null })).toBe('logs.eventLabels.memberNameRemoved:{"name":"bob"}');
});

test("lock policy label names the timeout and whether Lock vault is required", () => {
const set = (metadata: Record<string, unknown>) => ACTION_META["team.lock_policy_set"].label(log({ action: "team.lock_policy_set", metadata }));
expect(set({ max_minutes: 15, force_vault: false }))
.toBe('logs.eventLabels.lockPolicySet:{"timeout":"settings.account.sessionSecurity.timeout.15min"}');
expect(set({ max_minutes: 0, force_vault: true }))
.toBe('logs.eventLabels.lockPolicySetVault:{"timeout":"settings.account.sessionSecurity.timeout.immediately"}');
});
12 changes: 11 additions & 1 deletion src/components/logs/AuditEventRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { LOCAL_ACTOR_ID } from "@/services/localAuditService";
import { useIdentityStore } from "@/stores/identityStore";
import { findIdentityIn } from "@/services/credentialScope";
import { formatTime, HOUR_MINUTE } from "@/utils/localeFormat";
import { sessionTimeoutLabel, sessionTimeoutValue } from "@/utils/sessionTimeout";

// ─── Action metadata ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -78,6 +79,11 @@ export const ACTION_META: Record<string, ActionMeta> = {
"session.ended": { icon: "lucide:monitor", color: "#6b7280", label: () => i18n.t("logs.eventLabels.sessionEnded") },
"session.joined": { icon: "lucide:monitor", color: "#06b6d4", label: () => i18n.t("logs.eventLabels.sessionJoined") },
"session.left": { icon: "lucide:monitor", color: "#6b7280", label: () => i18n.t("logs.eventLabels.sessionLeft") },
"team.lock_policy_set": { icon: "lucide:shield-check", color: "#f59e0b", label: (l) => {
const timeout = sessionTimeoutLabel((k) => i18n.t(k), sessionTimeoutValue((l.metadata?.max_minutes as number | undefined) ?? null));
return i18n.t(l.metadata?.force_vault ? "logs.eventLabels.lockPolicySetVault" : "logs.eventLabels.lockPolicySet", { timeout });
} },
"team.lock_policy_removed": { icon: "lucide:shield-off", color: "#f59e0b", label: () => i18n.t("logs.eventLabels.lockPolicyRemoved") },
};

export const FALLBACK_META: ActionMeta = {
Expand All @@ -104,8 +110,12 @@ function AuditBadge({ accent, title, children }: { accent?: boolean; title?: str
);
}

export function actionOptionKey(action: string): string {
return `logs.filters.actionOptions.${action.replace(/[._](\w)/g, (_, c: string) => c.toUpperCase())}`;
}

export function actionName(action: string): string {
const key = `logs.filters.actionOptions.${action.replace(/[._](\w)/g, (_, c: string) => c.toUpperCase())}`;
const key = actionOptionKey(action);
return i18n.exists(key) ? i18n.t(key) : action;
}

Expand Down
46 changes: 12 additions & 34 deletions src/components/logs/AuditFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,22 @@ import { FilterInput } from "@/components/shared/ToolbarViewControls";
import { ToolbarDropdown } from "@/components/shared/ToolbarDropdown";
import { Pills } from "@/components/shared/Pills";
import { getAuditTimeRange, type AuditTimeRange } from "./auditLogToolbarUtils";
import { actionOptionKey } from "./AuditEventRow";

const FILTER_ACTIONS = [
"member.invited", "member.joined", "member.removed", "member.role_changed", "member.permissions_changed",
"connection.created", "connection.updated", "connection.deleted", "identity.created", "identity.updated",
"identity.deleted", "key.created", "key.updated", "key.deleted", "snippet.created", "snippet.updated",
"snippet.deleted", "folder.created", "folder.updated", "folder.deleted", "port_forward.created",
"port_forward.updated", "port_forward.deleted", "vault.renamed", "vault.key_rotated", "role.created",
"role.updated", "role.deleted", "connection.started", "connection.ended", "secret.viewed",
"session.started", "session.ended", "session.joined", "team.lock_policy_set", "team.lock_policy_removed",
];

function getActionOptions(t: TFunction) {
return [
{ value: "", label: t("logs.filters.actionOptions.all") },
{ value: "member.invited", label: t("logs.filters.actionOptions.memberInvited") },
{ value: "member.joined", label: t("logs.filters.actionOptions.memberJoined") },
{ value: "member.removed", label: t("logs.filters.actionOptions.memberRemoved") },
{ value: "member.role_changed", label: t("logs.filters.actionOptions.memberRoleChanged") },
{ value: "member.permissions_changed", label: t("logs.filters.actionOptions.memberPermissionsChanged") },
{ value: "connection.created", label: t("logs.filters.actionOptions.connectionCreated") },
{ value: "connection.updated", label: t("logs.filters.actionOptions.connectionUpdated") },
{ value: "connection.deleted", label: t("logs.filters.actionOptions.connectionDeleted") },
{ value: "identity.created", label: t("logs.filters.actionOptions.identityCreated") },
{ value: "identity.updated", label: t("logs.filters.actionOptions.identityUpdated") },
{ value: "identity.deleted", label: t("logs.filters.actionOptions.identityDeleted") },
{ value: "key.created", label: t("logs.filters.actionOptions.keyCreated") },
{ value: "key.updated", label: t("logs.filters.actionOptions.keyUpdated") },
{ value: "key.deleted", label: t("logs.filters.actionOptions.keyDeleted") },
{ value: "snippet.created", label: t("logs.filters.actionOptions.snippetCreated") },
{ value: "snippet.updated", label: t("logs.filters.actionOptions.snippetUpdated") },
{ value: "snippet.deleted", label: t("logs.filters.actionOptions.snippetDeleted") },
{ value: "folder.created", label: t("logs.filters.actionOptions.folderCreated") },
{ value: "folder.updated", label: t("logs.filters.actionOptions.folderUpdated") },
{ value: "folder.deleted", label: t("logs.filters.actionOptions.folderDeleted") },
{ value: "port_forward.created",label: t("logs.filters.actionOptions.portForwardCreated") },
{ value: "port_forward.updated",label: t("logs.filters.actionOptions.portForwardUpdated") },
{ value: "port_forward.deleted",label: t("logs.filters.actionOptions.portForwardDeleted") },
{ value: "vault.renamed", label: t("logs.filters.actionOptions.vaultRenamed") },
{ value: "vault.key_rotated", label: t("logs.filters.actionOptions.vaultKeyRotated") },
{ value: "role.created", label: t("logs.filters.actionOptions.roleCreated") },
{ value: "role.updated", label: t("logs.filters.actionOptions.roleUpdated") },
{ value: "role.deleted", label: t("logs.filters.actionOptions.roleDeleted") },
{ value: "connection.started", label: t("logs.filters.actionOptions.connectionStarted") },
{ value: "connection.ended", label: t("logs.filters.actionOptions.connectionEnded") },
{ value: "secret.viewed", label: t("logs.filters.actionOptions.secretViewed") },
{ value: "session.started", label: t("logs.filters.actionOptions.sessionStarted") },
{ value: "session.ended", label: t("logs.filters.actionOptions.sessionEnded") },
{ value: "session.joined", label: t("logs.filters.actionOptions.sessionJoined") },
...FILTER_ACTIONS.map((value) => ({ value, label: t(actionOptionKey(value)) })),
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const h = vi.hoisted(() => ({
loadRoles: vi.fn(),
loadPendingInvitations: vi.fn(),
clearMembersPanelPending: vi.fn(),
pending: "roles" as string,
perms: 7,
}));

vi.mock("react-i18next", () => ({
Expand Down Expand Up @@ -41,10 +43,13 @@ vi.mock("@/components/members/panels/RolesPanel", () => ({
PERM_META: {},
TeamRolesPanel: ({ teamId }: { teamId: string }) => <div data-testid="team-roles-panel">{teamId}</div>,
}));
vi.mock("@/components/members/panels/SecurityPolicyPanel", () => ({
SecurityPolicyPanel: () => <div data-testid="security-policy-panel" />,
}));
vi.mock("@/hooks/useListKeyNav", () => ({ useListKeyNav: () => ({ focusedId: null, setFocusedId: () => {} }) }));
vi.mock("@/hooks/usePermission", () => ({
PERM_BITS: { MANAGE_MEMBERS: 1, MANAGE_ROLES: 2, INVITE_MEMBERS: 4 },
effectivePermissions: () => 7,
effectivePermissions: () => h.perms,
hasBuiltinRole: () => false,
}));
vi.mock("@/services/teamService", () => ({
Expand Down Expand Up @@ -107,15 +112,15 @@ vi.mock("@/stores/subscriptionStore", () => {
);
return { useSubscriptionStore };
});
// membersPanelPending starts at "roles", as if the vault menu's Roles action had
// just run openMembersPanel("roles") before this page mounted.
// membersPanelPending starts at h.pending, as if a vault-menu action had just
// run openMembersPanel(h.pending) before this page mounted.
vi.mock("@/stores/uiStore", () => {
const state = {
membersLayoutMode: "list",
membersSortMode: "name-asc",
setMembersLayoutMode: vi.fn(),
setMembersSortMode: vi.fn(),
membersPanelPending: "roles",
get membersPanelPending() { return h.pending; },
clearMembersPanelPending: h.clearMembersPanelPending,
openSettings: vi.fn(),
openCloudAuth: vi.fn(),
Expand All @@ -139,6 +144,7 @@ vi.mock("@/stores/historyStore", () => ({
}));

import MembersPage from "./MembersPage";
import { VAULT_MANAGER_BITS } from "@/services/permissions";

beforeEach(() => {
h.getMyUserId.mockReset().mockResolvedValue("me");
Expand All @@ -148,7 +154,11 @@ beforeEach(() => {
h.loadRoles.mockReset().mockResolvedValue(undefined);
h.loadPendingInvitations.mockReset().mockResolvedValue(undefined);
h.clearMembersPanelPending.mockReset();
h.pending = "roles";
h.perms = 7;
});

const settle = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
afterEach(() => cleanup());

// The uncleared-flag case is the one that actually bites users: if the
Expand All @@ -157,9 +167,26 @@ afterEach(() => cleanup());
// whatever the user had open.
test("a pending roles request opens the roles panel and clears the flag", async () => {
render(<MembersPage />);
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
await settle();

expect(await screen.findByTestId("team-roles-panel")).toBeTruthy();
expect(screen.getByTestId("team-roles-panel").textContent).toBe("t1");
expect(h.clearMembersPanelPending).toHaveBeenCalledTimes(1);
});

test("a pending security request opens the security panel for a vault manager", async () => {
h.pending = "security";
h.perms = 7 | VAULT_MANAGER_BITS;
render(<MembersPage />);
await settle();

expect(await screen.findByTestId("security-policy-panel")).toBeTruthy();
});

test("the security panel stays hidden on a team the viewer does not manage", async () => {
h.pending = "security";
render(<MembersPage />);
await settle();

expect(screen.queryByTestId("security-policy-panel")).toBeNull();
});
14 changes: 8 additions & 6 deletions src/components/members/MembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,9 @@ const vaultTabs = selectedVaultIds.length > 1
}

// ── Team vault ─────────────────────────────────────────────────────────────
const panelOpen = panel !== null;
// Hidden, not cleared: a deep link opens it before this team's members (and so our permissions) load.
const shownPanel = panel === "security" && !canManageVault ? null : panel;
const panelOpen = shownPanel !== null;
const toolbarPanel = (title: string, icon: string, body: ReactNode) => (
<PanelShell>
<PanelHeader title={title} icon={icon} onClose={() => setPanel(null)} />
Expand All @@ -536,7 +538,7 @@ const vaultTabs = selectedVaultIds.length > 1
panelOpen={panelOpen}
panelWidth={320}
panel={
panel === "detail" && detailMember
shownPanel === "detail" && detailMember
? (
<MemberDetailPanel
key={detailMember.user_id}
Expand All @@ -552,7 +554,7 @@ const vaultTabs = selectedVaultIds.length > 1
onUpdated={reload}
/>
)
: panel === "invite"
: shownPanel === "invite"
? (
<InvitePanel
teamId={teamId}
Expand All @@ -563,9 +565,9 @@ const vaultTabs = selectedVaultIds.length > 1
onMemberAdded={reload}
/>
)
: panel === "roles" && myUserId
: shownPanel === "roles" && myUserId
? toolbarPanel(t("members.roles"), "lucide:shield", <TeamRolesPanel teamId={teamId} myUserId={myUserId} />)
: panel === "security"
: shownPanel === "security"
? toolbarPanel(t("members.security.title"), "lucide:shield-check", <SecurityPolicyPanel teamId={teamId} />)
: null
}
Expand All @@ -580,7 +582,7 @@ const vaultTabs = selectedVaultIds.length > 1
sortMode={sortMode}
onSortModeChange={setSortMode}
canInvite={canInvite}
activePanel={panel}
activePanel={shownPanel}
onTogglePanel={togglePanel}
pendingCount={pendingInvites.length || undefined}
canManageRoles={canManageRoles}
Expand Down
10 changes: 10 additions & 0 deletions src/components/members/panels/SecurityPolicyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,13 @@ test("below Business with a policy the controls are frozen and removal is offere
expect((screen.getByRole("switch", { name: "members.security.enforce" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText("members.security.remove")).toBeTruthy();
});

test("Immediately with Lock vault warns that members' sessions close on every leave", () => {
setTeam({ max_minutes: 0, force_vault: true });
render(<SecurityPolicyPanel teamId="t" />);
expect(screen.getByText("members.security.immediateVaultWarning")).toBeTruthy();
cleanup();
setTeam({ max_minutes: 5, force_vault: true });
render(<SecurityPolicyPanel teamId="t" />);
expect(screen.queryByText("members.security.immediateVaultWarning")).toBeNull();
});
5 changes: 4 additions & 1 deletion src/components/members/panels/SecurityPolicyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useBusinessLock } from "@/hooks/useBusinessLock";
import { runTeamAction } from "@/services/teamActionFeedback";
import type { TeamLockPolicy } from "@/services/teamService";
import { useTeamStore } from "@/stores/teamStore";
import { sessionTimeoutOptions } from "@/utils/sessionTimeout";
import { IMMEDIATELY, sessionTimeoutOptions } from "@/utils/sessionTimeout";

const DEFAULT_MAX_MINUTES = 15;

Expand Down Expand Up @@ -80,6 +80,9 @@ export function SecurityPolicyPanel({ teamId }: { teamId: string }) {
aria-label={t("members.security.forceVault")}
/>
</div>
{policy.max_minutes === IMMEDIATELY && policy.force_vault && (
<p className="text-xs text-(--t-status-warning)">{t("members.security.immediateVaultWarning")}</p>
)}
</>
)}
<p className="text-xs text-(--t-text-muted)">{t("members.security.appliesToAll")}</p>
Expand Down
1 change: 1 addition & 0 deletions src/components/settings/sections/AccountSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const PLAN_FEATURES = [
{ id: "teamSharing", free: false, pro: false, teams: true, business: true },
{ id: "seats", free: false, pro: false, teams: true, business: true },
{ id: "granularPermissions", free: false, pro: false, teams: false, business: true },
{ id: "lockPolicy", free: false, pro: false, teams: false, business: true },
];

async function openCheckout(plan: "pro" | "teams") {
Expand Down
2 changes: 1 addition & 1 deletion src/components/vault-admin/useVaultAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function useVaultAdmin(

const caps = target
? vaultAdminCapabilities(target, teams, rolesByTeam, myUserId)
: { isTeam: false, isOwner: false, canRename: false, canDelete: false, canMakePrivate: false, canLeave: false };
: { isTeam: false, isOwner: false, canRename: false, canSetLockPolicy: false, canDelete: false, canMakePrivate: false, canLeave: false };
const memberCount = target?.teamId ? (membersByTeam[target.teamId]?.length ?? null) : null;
// Mirrors MembersStack's render gate in VaultHeader: a private vault only has
// someone to share with once cloud sync is on, but a team vault always does.
Expand Down
13 changes: 9 additions & 4 deletions src/components/vault-admin/vaultAdminTarget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const memberRole = { id: "r-mem", name: "member", is_builtin: true };
const teamsAsOwner = [{ id: "t1", owner_id: "me", role_ids: ["r-own"] }];
const teamsAsMember = [{ id: "t1", owner_id: "boss", role_ids: ["r-mem"] }];
const roles = { t1: [ownerRole, memberRole] };
const managers = { t1: [{ id: "r-mem", name: "manager", is_builtin: false, permissions: PERM_BITS.MANAGE_VAULT }] };

const privateVault: VaultAdminTarget =
{ kind: "local", vaultId: "v1", teamId: null, name: "Personal stuff" };
Expand All @@ -19,7 +20,7 @@ const standaloneTeam: VaultAdminTarget =

test("a private local vault can be renamed and deleted, nothing else", () => {
expect(vaultAdminCapabilities(privateVault, [], {}, "me")).toEqual({
isTeam: false, isOwner: false, canRename: true, canDelete: true, canMakePrivate: false, canLeave: false,
isTeam: false, isOwner: false, canRename: true, canSetLockPolicy: false, canDelete: true, canMakePrivate: false, canLeave: false,
});
});

Expand All @@ -30,7 +31,7 @@ test("the built-in personal vault can be renamed but never deleted", () => {

test("a team vault owner can make it private again or delete it", () => {
expect(vaultAdminCapabilities(teamVault, teamsAsOwner, roles, "me")).toEqual({
isTeam: true, isOwner: true, canRename: true, canDelete: true, canMakePrivate: true, canLeave: false,
isTeam: true, isOwner: true, canRename: true, canSetLockPolicy: true, canDelete: true, canMakePrivate: true, canLeave: false,
});
});

Expand All @@ -47,17 +48,21 @@ test("a team vault member is not offered make-private", () => {

test("a standalone team vault's owner can rename and delete the team", () => {
expect(vaultAdminCapabilities(standaloneTeam, teamsAsOwner, roles, "me")).toEqual({
isTeam: true, isOwner: true, canRename: true, canDelete: true, canMakePrivate: false, canLeave: false,
isTeam: true, isOwner: true, canRename: true, canSetLockPolicy: true, canDelete: true, canMakePrivate: false, canLeave: false,
});
});

test("renaming a team vault renames the team, so it needs Manage vault", () => {
expect(vaultAdminCapabilities(teamVault, teamsAsMember, roles, "me").canRename).toBe(false);
const managers = { t1: [{ id: "r-mem", name: "manager", is_builtin: false, permissions: PERM_BITS.MANAGE_VAULT }] };
expect(vaultAdminCapabilities(teamVault, teamsAsMember, managers, "me").canRename).toBe(true);
expect(vaultAdminCapabilities(standaloneTeam, teamsAsMember, managers, "me").canRename).toBe(true);
});

test("a team lock policy needs Manage vault, as the server enforces", () => {
expect(vaultAdminCapabilities(teamVault, teamsAsMember, roles, "me").canSetLockPolicy).toBe(false);
expect(vaultAdminCapabilities(standaloneTeam, teamsAsMember, managers, "me").canSetLockPolicy).toBe(true);
});

test("ownership is the team's owner_id, as the server decides it, not a role name", () => {
const impostor = { t1: [{ id: "r-mem", name: "owner", is_builtin: false }] };
expect(vaultAdminCapabilities(teamVault, teamsAsMember, impostor, "me").isOwner).toBe(false);
Expand Down
Loading
Loading