diff --git a/src/components/logs/AuditEventRow.memberName.test.ts b/src/components/logs/AuditEventRow.memberName.test.ts index a25fb7b0d..29a098e90 100644 --- a/src/components/logs/AuditEventRow.memberName.test.ts +++ b/src/components/logs/AuditEventRow.memberName.test.ts @@ -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) => 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"}'); +}); diff --git a/src/components/logs/AuditEventRow.tsx b/src/components/logs/AuditEventRow.tsx index 68a71bc93..758351149 100644 --- a/src/components/logs/AuditEventRow.tsx +++ b/src/components/logs/AuditEventRow.tsx @@ -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 ────────────────────────────────────────────────────────── @@ -78,6 +79,11 @@ export const ACTION_META: Record = { "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 = { @@ -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; } diff --git a/src/components/logs/AuditFilters.tsx b/src/components/logs/AuditFilters.tsx index 426319fde..5106e88b5 100644 --- a/src/components/logs/AuditFilters.tsx +++ b/src/components/logs/AuditFilters.tsx @@ -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)) })), ]; } diff --git a/src/components/members/MembersPage.RolesPending.test.tsx b/src/components/members/MembersPage.PendingPanel.test.tsx similarity index 84% rename from src/components/members/MembersPage.RolesPending.test.tsx rename to src/components/members/MembersPage.PendingPanel.test.tsx index b090ad6da..2066859ca 100644 --- a/src/components/members/MembersPage.RolesPending.test.tsx +++ b/src/components/members/MembersPage.PendingPanel.test.tsx @@ -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", () => ({ @@ -41,10 +43,13 @@ vi.mock("@/components/members/panels/RolesPanel", () => ({ PERM_META: {}, TeamRolesPanel: ({ teamId }: { teamId: string }) =>
{teamId}
, })); +vi.mock("@/components/members/panels/SecurityPolicyPanel", () => ({ + SecurityPolicyPanel: () =>
, +})); 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", () => ({ @@ -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(), @@ -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"); @@ -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 @@ -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(); - 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(); + 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(); + await settle(); + + expect(screen.queryByTestId("security-policy-panel")).toBeNull(); +}); diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index 178d66158..251cad755 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -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) => ( setPanel(null)} /> @@ -536,7 +538,7 @@ const vaultTabs = selectedVaultIds.length > 1 panelOpen={panelOpen} panelWidth={320} panel={ - panel === "detail" && detailMember + shownPanel === "detail" && detailMember ? ( 1 onUpdated={reload} /> ) - : panel === "invite" + : shownPanel === "invite" ? ( 1 onMemberAdded={reload} /> ) - : panel === "roles" && myUserId + : shownPanel === "roles" && myUserId ? toolbarPanel(t("members.roles"), "lucide:shield", ) - : panel === "security" + : shownPanel === "security" ? toolbarPanel(t("members.security.title"), "lucide:shield-check", ) : null } @@ -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} diff --git a/src/components/members/panels/SecurityPolicyPanel.test.tsx b/src/components/members/panels/SecurityPolicyPanel.test.tsx index 7b21ef846..ef9b515a8 100644 --- a/src/components/members/panels/SecurityPolicyPanel.test.tsx +++ b/src/components/members/panels/SecurityPolicyPanel.test.tsx @@ -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(); + expect(screen.getByText("members.security.immediateVaultWarning")).toBeTruthy(); + cleanup(); + setTeam({ max_minutes: 5, force_vault: true }); + render(); + expect(screen.queryByText("members.security.immediateVaultWarning")).toBeNull(); +}); diff --git a/src/components/members/panels/SecurityPolicyPanel.tsx b/src/components/members/panels/SecurityPolicyPanel.tsx index 65f8ca523..93615c662 100644 --- a/src/components/members/panels/SecurityPolicyPanel.tsx +++ b/src/components/members/panels/SecurityPolicyPanel.tsx @@ -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; @@ -80,6 +80,9 @@ export function SecurityPolicyPanel({ teamId }: { teamId: string }) { aria-label={t("members.security.forceVault")} />
+ {policy.max_minutes === IMMEDIATELY && policy.force_vault && ( +

{t("members.security.immediateVaultWarning")}

+ )} )}

{t("members.security.appliesToAll")}

diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index 26cfdf934..9df9eead5 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -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") { diff --git a/src/components/vault-admin/useVaultAdmin.ts b/src/components/vault-admin/useVaultAdmin.ts index f42dd4853..6b48e4d52 100644 --- a/src/components/vault-admin/useVaultAdmin.ts +++ b/src/components/vault-admin/useVaultAdmin.ts @@ -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. diff --git a/src/components/vault-admin/vaultAdminTarget.test.ts b/src/components/vault-admin/vaultAdminTarget.test.ts index 30baaf000..65600fc8b 100644 --- a/src/components/vault-admin/vaultAdminTarget.test.ts +++ b/src/components/vault-admin/vaultAdminTarget.test.ts @@ -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" }; @@ -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, }); }); @@ -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, }); }); @@ -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); diff --git a/src/components/vault-admin/vaultAdminTarget.ts b/src/components/vault-admin/vaultAdminTarget.ts index 9dfbe5458..e80406dda 100644 --- a/src/components/vault-admin/vaultAdminTarget.ts +++ b/src/components/vault-admin/vaultAdminTarget.ts @@ -12,6 +12,7 @@ export interface VaultAdminCapabilities { isTeam: boolean; isOwner: boolean; canRename: boolean; + canSetLockPolicy: boolean; canDelete: boolean; canMakePrivate: boolean; canLeave: boolean; @@ -35,12 +36,13 @@ export function vaultAdminCapabilities( const roles = target.teamId ? rolesByTeam[target.teamId] ?? [] : []; const myRoles = (team?.role_ids ?? []).flatMap((rid) => roles.filter((role) => role.id === rid)); const isOwner = isTeamOwner(team, myUserId); - const managesVault = myRoles.some((r) => ((r.permissions ?? 0) & VAULT_MANAGER_BITS) !== 0); + const managesTeam = isOwner || myRoles.some((r) => ((r.permissions ?? 0) & VAULT_MANAGER_BITS) !== 0); return { isTeam, isOwner, - canRename: isTeam ? isOwner || managesVault : isLocal, + canRename: isTeam ? managesTeam : isLocal, + canSetLockPolicy: isTeam && managesTeam, // The server lets only the owner delete a team, and that deletes it for every member. canDelete: isTeam ? isOwner : isLocal && target.vaultId !== "personal", canMakePrivate: isTeam && isOwner && isLocal && target.vaultId !== null, diff --git a/src/components/vault-admin/vaultMenuItems.test.ts b/src/components/vault-admin/vaultMenuItems.test.ts index 6af9efbaf..ad5c50211 100644 --- a/src/components/vault-admin/vaultMenuItems.test.ts +++ b/src/components/vault-admin/vaultMenuItems.test.ts @@ -7,13 +7,13 @@ const labels = (caps: VaultAdminCapabilities, memberCount: number | null = null, vaultMenuItems({ caps, memberCount, canShare, t, on: vi.fn() }).map((i) => i.label); const privateCaps: VaultAdminCapabilities = - { 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 }; const ownerCaps: VaultAdminCapabilities = - { 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 }; const memberCaps: VaultAdminCapabilities = - { isTeam: true, isOwner: false, canRename: true, canDelete: false, canMakePrivate: false, canLeave: true }; + { isTeam: true, isOwner: false, canRename: true, canSetLockPolicy: true, canDelete: false, canMakePrivate: false, canLeave: true }; const cloudCaps: VaultAdminCapabilities = - { isTeam: true, isOwner: true, canRename: false, canDelete: false, canMakePrivate: false, canLeave: false }; + { isTeam: true, isOwner: true, canRename: false, canSetLockPolicy: false, canDelete: false, canMakePrivate: false, canLeave: false }; test("a private vault gets no members, roles or make-private entries", () => { expect(labels(privateCaps)).toEqual([ @@ -104,4 +104,6 @@ test("only vault managers of a team get Security policy", () => { expect(labels(ownerCaps, 2)).toContain("layout.vaultMenu.security"); expect(labels(cloudCaps, 2)).not.toContain("layout.vaultMenu.security"); expect(labels(privateCaps)).not.toContain("layout.vaultMenu.security"); + expect(labels({ ...ownerCaps, canSetLockPolicy: false })).not.toContain("layout.vaultMenu.security"); + expect(labels({ ...cloudCaps, canSetLockPolicy: true })).toContain("layout.vaultMenu.security"); }); diff --git a/src/components/vault-admin/vaultMenuItems.ts b/src/components/vault-admin/vaultMenuItems.ts index 23363833d..62ecb0067 100644 --- a/src/components/vault-admin/vaultMenuItems.ts +++ b/src/components/vault-admin/vaultMenuItems.ts @@ -31,7 +31,7 @@ export function vaultMenuItems({ onClick: () => on("members"), }); items.push({ label: t("layout.vaultMenu.roles"), icon: "lucide:shield", onClick: () => on("roles") }); - if (caps.canRename) { + if (caps.canSetLockPolicy) { items.push({ label: t("layout.vaultMenu.security"), icon: "lucide:shield-check", onClick: () => on("security") }); } } diff --git a/src/i18n/locales/cs/logs.json b/src/i18n/locales/cs/logs.json index f7a120990..e41a8b293 100644 --- a/src/i18n/locales/cs/logs.json +++ b/src/i18n/locales/cs/logs.json @@ -44,6 +44,8 @@ "portForwardDeleted": "Přesměrování portu smazáno", "vaultRenamed": "Trezor přejmenován", "vaultKeyRotated": "Klíč obměněn", + "teamLockPolicySet": "Zásady zamykání nastaveny", + "teamLockPolicyRemoved": "Zásady zamykání odebrány", "roleCreated": "Role vytvořena", "roleUpdated": "Role upravena", "roleDeleted": "Role smazána", @@ -97,6 +99,9 @@ "vaultDeleted": "smazal(a) trezor „{{name}}“", "vaultRenamed": "přejmenoval(a) trezor na „{{name}}“", "vaultKeyRotated": "obměnil(a) klíč trezoru „{{name}}“", + "lockPolicySet": "nastavil(a) zásady zamykání (automatické zamčení: {{timeout}})", + "lockPolicySetVault": "nastavil(a) zásady zamykání (automatické zamčení: {{timeout}}, vyžadováno „Zamknout trezor“)", + "lockPolicyRemoved": "odebral(a) zásady zamykání", "roleCreated": "vytvořil(a) roli „{{name}}“", "roleUpdated": "upravil(a) roli „{{name}}“", "roleDeleted": "smazal(a) roli „{{name}}“", diff --git a/src/i18n/locales/cs/members.json b/src/i18n/locales/cs/members.json index 640d2742a..5c3d18801 100644 --- a/src/i18n/locales/cs/members.json +++ b/src/i18n/locales/cs/members.json @@ -330,6 +330,7 @@ "maxLabel": "Zamknout nejpozději po", "forceVault": "Vyžadovat „Zamknout trezor“", "forceVaultDesc": "Členové nemohou zvolit zamčení obrazovky, takže zamčení vždy odstraní klíč trezoru z paměti.", + "immediateVaultWarning": "Při volbě „Okamžitě“ se zámkem trezoru se otevřené relace členů zavřou pokaždé, když opustí Voltius, i jen na chvíli.", "appliesToAll": "Platí pro všechny členy včetně vás. Starší verze Voltius ji nevynucují.", "lockBody": "Zásady zamykání vyžadují tarif Business týmu.", "lapsed": "Tarif Business vypršel: tato zásada stále platí. Můžete ji odebrat.", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index c9e3046e4..5beba6a4a 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -398,6 +398,7 @@ "teamVaults": "Sdílené týmové trezory", "teamSharing": "Týmové sdílení (5 relací · 10 hostů)", "seats": "Místa (dle nákupu)", + "lockPolicy": "Týmové zásady zamykání (limit automatického zamčení, zámek trezoru)", "granularPermissions": "Vlastní role, oprávnění pro jednotlivé členy a objekty" } }, diff --git a/src/i18n/locales/en/logs.json b/src/i18n/locales/en/logs.json index 7b99f59ca..0e950c809 100644 --- a/src/i18n/locales/en/logs.json +++ b/src/i18n/locales/en/logs.json @@ -44,6 +44,8 @@ "portForwardDeleted": "Port forward deleted", "vaultRenamed": "Vault renamed", "vaultKeyRotated": "Key rotated", + "teamLockPolicySet": "Lock policy set", + "teamLockPolicyRemoved": "Lock policy removed", "roleCreated": "Role created", "roleUpdated": "Role updated", "roleDeleted": "Role deleted", @@ -97,6 +99,9 @@ "vaultDeleted": "deleted vault \"{{name}}\"", "vaultRenamed": "renamed vault to \"{{name}}\"", "vaultKeyRotated": "rotated key for vault \"{{name}}\"", + "lockPolicySet": "set the lock policy (auto-lock: {{timeout}})", + "lockPolicySetVault": "set the lock policy (auto-lock: {{timeout}}, Lock vault required)", + "lockPolicyRemoved": "removed the lock policy", "roleCreated": "created role \"{{name}}\"", "roleUpdated": "updated role \"{{name}}\"", "roleDeleted": "deleted role \"{{name}}\"", diff --git a/src/i18n/locales/en/members.json b/src/i18n/locales/en/members.json index ba84eb9f5..63e00d2c3 100644 --- a/src/i18n/locales/en/members.json +++ b/src/i18n/locales/en/members.json @@ -310,6 +310,7 @@ "maxLabel": "Lock after at most", "forceVault": "Require Lock vault", "forceVaultDesc": "Members can't pick Lock screen, so locking always removes the vault key from memory.", + "immediateVaultWarning": "With Immediately and Lock vault, members' open sessions close every time they leave Voltius, even briefly.", "appliesToAll": "Applies to every member, you included. Older Voltius versions don't enforce it.", "lockBody": "Lock policies need the team's Business plan.", "lapsed": "Business plan lapsed: this policy stays enforced. You can remove it.", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index f5623e242..3ef88666a 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -398,6 +398,7 @@ "teamVaults": "Shared team vaults", "teamSharing": "Team sharing (5 sessions · 10 guests)", "seats": "Seats (as purchased)", + "lockPolicy": "Team lock policy (auto-lock limit, Lock vault)", "granularPermissions": "Custom roles, per-member & per-object permissions" } }, diff --git a/src/i18n/locales/fr/logs.json b/src/i18n/locales/fr/logs.json index a82699c81..5873e959c 100644 --- a/src/i18n/locales/fr/logs.json +++ b/src/i18n/locales/fr/logs.json @@ -44,6 +44,8 @@ "portForwardDeleted": "Redirection de port supprimée", "vaultRenamed": "Coffre renommé", "vaultKeyRotated": "Clé changée", + "teamLockPolicySet": "Règle de verrouillage définie", + "teamLockPolicyRemoved": "Règle de verrouillage supprimée", "roleCreated": "Rôle créé", "roleUpdated": "Rôle modifié", "roleDeleted": "Rôle supprimé", @@ -97,6 +99,9 @@ "vaultDeleted": "a supprimé le coffre « {{name}} »", "vaultRenamed": "a renommé le coffre en « {{name}} »", "vaultKeyRotated": "a changé la clé du coffre « {{name}} »", + "lockPolicySet": "a défini la règle de verrouillage (verrouillage auto : {{timeout}})", + "lockPolicySetVault": "a défini la règle de verrouillage (verrouillage auto : {{timeout}}, « Verrouiller le coffre » imposé)", + "lockPolicyRemoved": "a supprimé la règle de verrouillage", "roleCreated": "a créé le rôle « {{name}} »", "roleUpdated": "a modifié le rôle « {{name}} »", "roleDeleted": "a supprimé le rôle « {{name}} »", diff --git a/src/i18n/locales/fr/members.json b/src/i18n/locales/fr/members.json index d42db268b..fd054f225 100644 --- a/src/i18n/locales/fr/members.json +++ b/src/i18n/locales/fr/members.json @@ -318,6 +318,7 @@ "maxLabel": "Verrouiller au plus tard après", "forceVault": "Imposer « Verrouiller le coffre »", "forceVaultDesc": "Les membres ne peuvent pas choisir « Verrouiller l'écran » : le verrouillage retire toujours la clé du coffre de la mémoire.", + "immediateVaultWarning": "Avec « Immédiatement » et « Verrouiller le coffre », les sessions ouvertes des membres se ferment chaque fois qu'ils quittent Voltius, même un instant.", "appliesToAll": "S'applique à tous les membres, vous compris. Les anciennes versions de Voltius ne l'appliquent pas.", "lockBody": "Les règles de verrouillage nécessitent l'offre Business de l'équipe.", "lapsed": "Offre Business expirée : cette règle reste appliquée. Vous pouvez la supprimer.", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index a43fd2451..928f5c772 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -398,6 +398,7 @@ "teamVaults": "Coffres-forts d'équipe partagés", "teamSharing": "Partage d'équipe (5 sessions · 10 invités)", "seats": "Sièges (selon l'abonnement)", + "lockPolicy": "Règle de verrouillage d'équipe (délai maximal, verrouillage du coffre)", "granularPermissions": "Rôles personnalisés, permissions par membre et par objet" } }, diff --git a/src/i18n/locales/ru/logs.json b/src/i18n/locales/ru/logs.json index b04c70b99..7690f6a36 100644 --- a/src/i18n/locales/ru/logs.json +++ b/src/i18n/locales/ru/logs.json @@ -44,6 +44,8 @@ "portForwardDeleted": "Проброс портов удалён", "vaultRenamed": "Хранилище переименовано", "vaultKeyRotated": "Ротация ключа хранилища", + "teamLockPolicySet": "Политика блокировки задана", + "teamLockPolicyRemoved": "Политика блокировки удалена", "roleCreated": "Роль создана", "roleUpdated": "Роль обновлена", "roleDeleted": "Роль удалена", @@ -97,6 +99,9 @@ "vaultDeleted": "удалил(а) хранилище \"{{name}}\"", "vaultRenamed": "переименовал(а) хранилище в \"{{name}}\"", "vaultKeyRotated": "выполнил(а) ротацию ключа хранилища \"{{name}}\"", + "lockPolicySet": "задал(а) политику блокировки (автоблокировка: {{timeout}})", + "lockPolicySetVault": "задал(а) политику блокировки (автоблокировка: {{timeout}}, «Заблокировать хранилище» обязательно)", + "lockPolicyRemoved": "удалил(а) политику блокировки", "roleCreated": "создал(а) роль \"{{name}}\"", "roleUpdated": "обновил(а) роль \"{{name}}\"", "roleDeleted": "удалил(а) роль \"{{name}}\"", diff --git a/src/i18n/locales/ru/members.json b/src/i18n/locales/ru/members.json index d377ebb8c..f46d652db 100644 --- a/src/i18n/locales/ru/members.json +++ b/src/i18n/locales/ru/members.json @@ -326,6 +326,7 @@ "maxLabel": "Блокировать не позже чем через", "forceVault": "Требовать «Заблокировать хранилище»", "forceVaultDesc": "Участники не смогут выбрать блокировку экрана, поэтому блокировка всегда удаляет ключ хранилища из памяти.", + "immediateVaultWarning": "При «Сразу» и блокировке хранилища открытые сессии участников закрываются каждый раз, когда они покидают Voltius, даже ненадолго.", "appliesToAll": "Действует для всех участников, включая вас. Старые версии Voltius её не применяют.", "lockBody": "Политики блокировки доступны в тарифе Business.", "lapsed": "Тариф Business истёк: политика продолжает действовать. Её можно удалить.", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 16a2ea516..f92a2381c 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -398,6 +398,7 @@ "teamVaults": "Общие командные хранилища", "teamSharing": "Командный доступ (5 сессий · 10 гостей)", "seats": "Места (по подписке)", + "lockPolicy": "Политика блокировки команды (лимит автоблокировки, блокировка хранилища)", "granularPermissions": "Свои роли, права участников и объектов" } }, diff --git a/src/i18n/locales/tr/logs.json b/src/i18n/locales/tr/logs.json index f0dbffc6d..766f240e5 100644 --- a/src/i18n/locales/tr/logs.json +++ b/src/i18n/locales/tr/logs.json @@ -44,6 +44,8 @@ "portForwardDeleted": "Port yönlendirme silindi", "vaultRenamed": "Kasa yeniden adlandırıldı", "vaultKeyRotated": "Anahtar yenilendi", + "teamLockPolicySet": "Kilit politikası ayarlandı", + "teamLockPolicyRemoved": "Kilit politikası kaldırıldı", "roleCreated": "Rol oluşturuldu", "roleUpdated": "Rol güncellendi", "roleDeleted": "Rol silindi", @@ -97,6 +99,9 @@ "vaultDeleted": "\"{{name}}\" kasasını sildi", "vaultRenamed": "kasayı \"{{name}}\" olarak yeniden adlandırdı", "vaultKeyRotated": "\"{{name}}\" kasasının anahtarını yeniledi", + "lockPolicySet": "kilit politikasını ayarladı (otomatik kilit: {{timeout}})", + "lockPolicySetVault": "kilit politikasını ayarladı (otomatik kilit: {{timeout}}, \"Kasayı kilitle\" zorunlu)", + "lockPolicyRemoved": "kilit politikasını kaldırdı", "roleCreated": "\"{{name}}\" rolünü oluşturdu", "roleUpdated": "\"{{name}}\" rolünü güncelledi", "roleDeleted": "\"{{name}}\" rolünü sildi", diff --git a/src/i18n/locales/tr/members.json b/src/i18n/locales/tr/members.json index c52eb0307..26a29afeb 100644 --- a/src/i18n/locales/tr/members.json +++ b/src/i18n/locales/tr/members.json @@ -314,6 +314,7 @@ "maxLabel": "En geç şu süre sonra kilitle", "forceVault": "\"Kasayı kilitle\" zorunlu olsun", "forceVaultDesc": "Üyeler ekran kilidini seçemez, bu yüzden kilitleme kasa anahtarını her zaman bellekten siler.", + "immediateVaultWarning": "Hemen ve Kasayı kilitle birlikteyken, üyelerin açık oturumları Voltius'tan her ayrıldıklarında, kısa süreliğine bile olsa kapanır.", "appliesToAll": "Siz dahil her üyeye uygulanır. Eski Voltius sürümleri bunu uygulamaz.", "lockBody": "Kilit politikaları ekibin Business planını gerektirir.", "lapsed": "Business planı sona erdi: bu politika uygulanmaya devam ediyor. Kaldırabilirsiniz.", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index efc69e02f..a76dd9c67 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -404,6 +404,7 @@ "teamVaults": "Paylaşılan ekip kasaları", "teamSharing": "Ekip paylaşımı (5 oturum · 10 konuk)", "seats": "Lisanslar (satın alınan kadar)", + "lockPolicy": "Ekip kilit politikası (otomatik kilit sınırı, Kasayı kilitle)", "granularPermissions": "Özel roller, üye ve nesne bazlı izinler" } }, diff --git a/src/i18n/locales/zh/logs.json b/src/i18n/locales/zh/logs.json index 9cbb80e78..f36fa5b52 100644 --- a/src/i18n/locales/zh/logs.json +++ b/src/i18n/locales/zh/logs.json @@ -44,6 +44,8 @@ "portForwardDeleted": "已删除端口转发", "vaultRenamed": "保险库已重命名", "vaultKeyRotated": "密钥已轮换", + "teamLockPolicySet": "已设置锁定策略", + "teamLockPolicyRemoved": "已移除锁定策略", "roleCreated": "已创建角色", "roleUpdated": "已更新角色", "roleDeleted": "已删除角色", @@ -97,6 +99,9 @@ "vaultDeleted": "删除了保险库 \"{{name}}\"", "vaultRenamed": "将保险库重命名为 \"{{name}}\"", "vaultKeyRotated": "为保险库 \"{{name}}\" 轮换密钥", + "lockPolicySet": "设置了锁定策略(自动锁定:{{timeout}})", + "lockPolicySetVault": "设置了锁定策略(自动锁定:{{timeout}},要求“锁定保险库”)", + "lockPolicyRemoved": "移除了锁定策略", "roleCreated": "创建了角色 \"{{name}}\"", "roleUpdated": "更新了角色 \"{{name}}\"", "roleDeleted": "删除了角色 \"{{name}}\"", diff --git a/src/i18n/locales/zh/members.json b/src/i18n/locales/zh/members.json index 48b862b6a..7203e361f 100644 --- a/src/i18n/locales/zh/members.json +++ b/src/i18n/locales/zh/members.json @@ -310,6 +310,7 @@ "maxLabel": "最长锁定时间", "forceVault": "要求“锁定保险库”", "forceVaultDesc": "成员无法选择锁定屏幕,因此锁定时总会从内存中移除保险库密钥。", + "immediateVaultWarning": "同时选择“立即”和“锁定保险库”时,成员每次离开 Voltius(即使很短暂)都会关闭已打开的会话。", "appliesToAll": "适用于所有成员,包括你。旧版 Voltius 不会执行此策略。", "lockBody": "锁定策略需要团队的 Business 方案。", "lapsed": "Business 方案已失效:此策略仍然生效,你可以将其移除。", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index eaad6aa88..f217cbfaa 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -292,6 +292,7 @@ "teamVaults": "共享团队保险库", "teamSharing": "团队共享(5 个会话 · 10 名访客)", "seats": "席位(按订阅数量)", + "lockPolicy": "团队锁定策略(自动锁定上限、锁定保险库)", "granularPermissions": "自定义角色、按成员和按对象的权限" } }, diff --git a/src/services/vault.test.ts b/src/services/vault.test.ts index f5e3ce132..e50bd7aca 100644 --- a/src/services/vault.test.ts +++ b/src/services/vault.test.ts @@ -16,11 +16,13 @@ import { getLocalSecret, purgeLocalSecrets, quarantineVault, + resetVault, unlockVaultIfNeeded, verifyVaultKey, wipeLocalConfig, } from "./vault"; import { VaultLockedError, VaultUnreadableError, vaultErrorCode } from "./vaultErrors"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; const KEY = [1, 2, 3]; // What secrets.rs sends over IPC (its `a_wrong_key_sends_…`/`a_locked_store_sends_…` tests). @@ -158,3 +160,9 @@ test("a wipe with nothing to carry leaves the keychain alone", async () => { expect(invoked("keychain_set")).toBe(false); expect(invoked("config_wipe")).toBe(true); }); + +test("resetVault drops the team lock policy held in memory", async () => { + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 15, forceVault: true } }); + await resetVault(); + expect(useOrgLockPolicyStore.getState().policy).toBeNull(); +}); diff --git a/src/services/vault.ts b/src/services/vault.ts index 5300d5aaf..5861e9175 100644 --- a/src/services/vault.ts +++ b/src/services/vault.ts @@ -1,4 +1,5 @@ import { invoke } from "@/lib/invoke"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; import { clearPersistedAccountUiState } from "@/stores/persistedAccountUiState"; import { ACCOUNT_CACHE_KEYS } from "./accountCacheKeys"; import { backendErrorCode } from "./backendErrors"; @@ -158,6 +159,8 @@ export async function readLocalSecrets(keys: string[]): Promise { pendingKey = null; unlocked = false; + // Before the storage wipe: the store writes itself back to localStorage on set. + useOrgLockPolicyStore.getState().setPolicy(null); clearPersistedAccountUiState(); await invoke("secrets_lock"); await invoke("vault_reset"); // deletes secrets.enc + connections.json + legacy vault.hold