diff --git a/src/components/layout/SidebarAccountButton.tsx b/src/components/layout/SidebarAccountButton.tsx index a59ea3556..09c782a86 100644 --- a/src/components/layout/SidebarAccountButton.tsx +++ b/src/components/layout/SidebarAccountButton.tsx @@ -14,6 +14,7 @@ import { DropdownMenuItem } from "@/components/shared/DropdownMenuItem"; import { useCopyHandle } from "@/hooks/useCopyHandle"; import { useNotificationStore } from "@/stores/notificationStore"; import { useSecurityStore } from "@/stores/securityStore"; +import { useEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings"; import { canLockApp } from "@/utils/accountMode"; import { instanceLabel } from "@/utils/serverInstance"; import { IMMEDIATELY, sessionTimeoutLabel, sessionTimeoutValue } from "@/utils/sessionTimeout"; @@ -40,7 +41,7 @@ export function SidebarAccountButton() { const [accountServerUrl, setAccountServerUrl] = useState(null); const [pendingSwitch, setPendingSwitch] = useState(null); const { copied: handleCopied, copy: copyHandle } = useCopyHandle(accountHandle); - const sessionTimeoutMinutes = useSecurityStore((s) => s.sessionTimeoutMinutes); + const { sessionTimeoutMinutes } = useEffectiveLockSettings(); const systemAuthUnlock = useSecurityStore((s) => s.systemAuthUnlock); const refreshAccountInfo = async (): Promise => { diff --git a/src/components/layout/SplashScreen.appLock.test.tsx b/src/components/layout/SplashScreen.appLock.test.tsx index 9cf51e480..5cfe7eeb1 100644 --- a/src/components/layout/SplashScreen.appLock.test.tsx +++ b/src/components/layout/SplashScreen.appLock.test.tsx @@ -6,6 +6,8 @@ const h = vi.hoisted(() => ({ autoLogin: vi.fn(async () => "ok"), systemAuthUnlock: false, available: true, + exists: true, + serverMode: false, authProps: null as null | { isLocked: boolean; systemAuth?: boolean }, })); @@ -18,13 +20,13 @@ vi.mock("@/services/account", () => ({ autoLogin: h.autoLogin, getAppLock: vi.fn(async () => h.lock), setAppLock: vi.fn(async () => {}), - isServerMode: vi.fn(async () => false), + isServerMode: vi.fn(async () => h.serverMode), })); vi.mock("@/services/appLock", () => ({ systemAuthAvailable: vi.fn(async () => h.available) })); vi.mock("@/stores/securityStore", () => ({ useSecurityStore: { getState: () => ({ systemAuthUnlock: h.systemAuthUnlock }) }, })); -vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: true })) })); +vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: h.exists })) })); vi.mock("./AuthPage", () => ({ default: (p: { isLocked: boolean; systemAuth?: boolean }) => { h.authProps = p; return
auth-page
; }, })); @@ -36,6 +38,8 @@ beforeEach(() => { vi.useFakeTimers(); h.lock = null; h.systemAuthUnlock = false; + h.exists = true; + h.serverMode = false; h.authProps = null; h.autoLogin.mockClear(); }); @@ -74,3 +78,20 @@ test("a screen lock that survived a restart is up before the shell shows", async expect(h.autoLogin).toHaveBeenCalled(); expect(lockedAtReady).toBe("screen"); }); + +test("a vault-locked cloud account with no vault file yet still gets the unlock page", async () => { + h.lock = "vault"; + h.exists = false; + h.serverMode = true; + render( {}} />); + await advance(2000); + expect(h.authProps).toMatchObject({ isLocked: true }); +}); + +test("a vault lock with no vault file and no cloud account is a first launch", async () => { + h.lock = "vault"; + h.exists = false; + render( {}} />); + await advance(2000); + expect(h.authProps).toMatchObject({ isLocked: false }); +}); diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx index 1a49efbc9..287446fe3 100644 --- a/src/components/layout/SplashScreen.tsx +++ b/src/components/layout/SplashScreen.tsx @@ -67,9 +67,10 @@ export default function SplashScreen({ onReady }: Props) { if ((await getAppLock()) === "vault") { setSystemAuth(useSecurityStore.getState().systemAuthUnlock && (await systemAuthAvailable())); try { - const { exists } = await getVaultStatus(); - setStep("vault", "done", exists ? t("layout.splash.vaultLocked") : t("layout.splash.firstLaunch")); - setPhase(exists ? "auth-locked" : "auth-first-launch"); + // A cloud account has nothing on disk until its first secret; login() re-derives from the server. + const locked = (await getVaultStatus()).exists || (await isServerMode()); + setStep("vault", "done", locked ? t("layout.splash.vaultLocked") : t("layout.splash.firstLaunch")); + setPhase(locked ? "auth-locked" : "auth-first-launch"); } catch { setStep("vault", "error", t("layout.splash.vaultCheckFailed")); setPhase("auth-first-launch"); diff --git a/src/components/layout/VaultHeader.menu.test.tsx b/src/components/layout/VaultHeader.menu.test.tsx index b8ed01f54..5b6af875c 100644 --- a/src/components/layout/VaultHeader.menu.test.tsx +++ b/src/components/layout/VaultHeader.menu.test.tsx @@ -38,7 +38,7 @@ beforeEach(() => { teams: [], membersByTeam: {}, rolesByTeam: {}, loadMembers: vi.fn(async () => {}), }); - useUIStore.setState({ activeNav: "hosts", homeView: true, membersRolesPending: false, vaultSharePending: false }); + useUIStore.setState({ activeNav: "hosts", homeView: true, membersPanelPending: null, vaultSharePending: false }); useSubscriptionStore.setState({ accountMode: "server" }); }); afterEach(cleanup); @@ -79,7 +79,7 @@ test("choosing Members navigates to the Members page without marking roles pendi const state = useUIStore.getState(); expect(state.activeNav).toBe("members"); expect(state.homeView).toBe(false); - expect(state.membersRolesPending).toBe(false); + expect(state.membersPanelPending).toBeNull(); }); test("choosing Roles navigates to the Members page and marks the roles panel pending", () => { @@ -96,7 +96,7 @@ test("choosing Roles navigates to the Members page and marks the roles panel pen const state = useUIStore.getState(); expect(state.activeNav).toBe("members"); expect(state.homeView).toBe(false); - expect(state.membersRolesPending).toBe(true); + expect(state.membersPanelPending).toBe("roles"); }); // The rail's Share… action has no share sheet of its own — it switches to the diff --git a/src/components/layout/VaultHeader.tsx b/src/components/layout/VaultHeader.tsx index 58d31d51a..8011359f4 100644 --- a/src/components/layout/VaultHeader.tsx +++ b/src/components/layout/VaultHeader.tsx @@ -44,7 +44,7 @@ export function MembersStack({ hasTeam?: boolean; }) { const { t } = useTranslation(); - const openMembersInvite = useUIStore((s) => s.openMembersInvite); + const openMembersPanel = useUIStore((s) => s.openMembersPanel); const [invHovered, setInvHovered] = useState(false); const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const open = openProp ?? uncontrolledOpen; @@ -168,7 +168,7 @@ export function MembersStack({ glass >
- + openMembersPanel("invite")} />
diff --git a/src/components/layout/VaultRailShareTarget.test.tsx b/src/components/layout/VaultRailShareTarget.test.tsx index df112850b..0e1b05a1a 100644 --- a/src/components/layout/VaultRailShareTarget.test.tsx +++ b/src/components/layout/VaultRailShareTarget.test.tsx @@ -116,5 +116,5 @@ test("right-clicking a second vault while a different one is active and choosing expect(useVaultStore.getState().selectedVaultIds[0]).toBe("v2"); expect(useUIStore.getState().activeNav).toBe("members"); - expect(useUIStore.getState().membersRolesPending).toBe(true); + expect(useUIStore.getState().membersPanelPending).toBe("roles"); }); diff --git a/src/components/members/MembersPage.BulkActions.test.tsx b/src/components/members/MembersPage.BulkActions.test.tsx index 6bd5067ce..d8042120e 100644 --- a/src/components/members/MembersPage.BulkActions.test.tsx +++ b/src/components/members/MembersPage.BulkActions.test.tsx @@ -165,8 +165,8 @@ vi.mock("@/stores/uiStore", () => { membersSortMode: "name-asc", setMembersLayoutMode: vi.fn(), setMembersSortMode: vi.fn(), - membersInvitePending: false, - clearMembersInvitePending: vi.fn(), + membersPanelPending: null, + clearMembersPanelPending: vi.fn(), openSettings: vi.fn(), openCloudAuth: vi.fn(), }; diff --git a/src/components/members/MembersPage.InviteToSession.test.tsx b/src/components/members/MembersPage.InviteToSession.test.tsx index 28e00463b..c1d17b5a7 100644 --- a/src/components/members/MembersPage.InviteToSession.test.tsx +++ b/src/components/members/MembersPage.InviteToSession.test.tsx @@ -168,8 +168,8 @@ vi.mock("@/stores/uiStore", () => { membersSortMode: "name-asc", setMembersLayoutMode: vi.fn(), setMembersSortMode: vi.fn(), - membersInvitePending: false, - clearMembersInvitePending: vi.fn(), + membersPanelPending: null, + clearMembersPanelPending: vi.fn(), openSettings: vi.fn(), openCloudAuth: vi.fn(), }; diff --git a/src/components/members/MembersPage.RolesPending.test.tsx b/src/components/members/MembersPage.RolesPending.test.tsx index 117ef32fc..b090ad6da 100644 --- a/src/components/members/MembersPage.RolesPending.test.tsx +++ b/src/components/members/MembersPage.RolesPending.test.tsx @@ -8,7 +8,7 @@ const h = vi.hoisted(() => ({ loadMembers: vi.fn(), loadRoles: vi.fn(), loadPendingInvitations: vi.fn(), - clearMembersRolesPending: vi.fn(), + clearMembersPanelPending: vi.fn(), })); vi.mock("react-i18next", () => ({ @@ -107,18 +107,16 @@ vi.mock("@/stores/subscriptionStore", () => { ); return { useSubscriptionStore }; }); -// membersRolesPending starts true, as if the vault menu's Roles action had -// just run openMembersRoles() before this page mounted. +// membersPanelPending starts at "roles", as if the vault menu's Roles action had +// just run openMembersPanel("roles") before this page mounted. vi.mock("@/stores/uiStore", () => { const state = { membersLayoutMode: "list", membersSortMode: "name-asc", setMembersLayoutMode: vi.fn(), setMembersSortMode: vi.fn(), - membersInvitePending: false, - clearMembersInvitePending: vi.fn(), - membersRolesPending: true, - clearMembersRolesPending: h.clearMembersRolesPending, + membersPanelPending: "roles", + clearMembersPanelPending: h.clearMembersPanelPending, openSettings: vi.fn(), openCloudAuth: vi.fn(), }; @@ -149,12 +147,12 @@ beforeEach(() => { h.loadMembers.mockReset().mockResolvedValue(undefined); h.loadRoles.mockReset().mockResolvedValue(undefined); h.loadPendingInvitations.mockReset().mockResolvedValue(undefined); - h.clearMembersRolesPending.mockReset(); + h.clearMembersPanelPending.mockReset(); }); afterEach(() => cleanup()); // The uncleared-flag case is the one that actually bites users: if the -// membersRolesPending effect fires without clearing the flag, the roles +// membersPanelPending effect fires without clearing the flag, the roles // panel re-opens on every later visit to the Members page, overriding // whatever the user had open. test("a pending roles request opens the roles panel and clears the flag", async () => { @@ -163,5 +161,5 @@ test("a pending roles request opens the roles panel and clears the flag", async expect(await screen.findByTestId("team-roles-panel")).toBeTruthy(); expect(screen.getByTestId("team-roles-panel").textContent).toBe("t1"); - expect(h.clearMembersRolesPending).toHaveBeenCalledTimes(1); + expect(h.clearMembersPanelPending).toHaveBeenCalledTimes(1); }); diff --git a/src/components/members/MembersPage.SelfCard.test.tsx b/src/components/members/MembersPage.SelfCard.test.tsx index 2bb6bef1d..db18983aa 100644 --- a/src/components/members/MembersPage.SelfCard.test.tsx +++ b/src/components/members/MembersPage.SelfCard.test.tsx @@ -100,7 +100,7 @@ vi.mock("@/stores/uiStore", () => { const state = { membersLayoutMode: "list", membersSortMode: "name-asc", setMembersLayoutMode: vi.fn(), setMembersSortMode: vi.fn(), - membersInvitePending: false, clearMembersInvitePending: vi.fn(), + membersPanelPending: null, clearMembersPanelPending: vi.fn(), openSettings: vi.fn(), openCloudAuth: vi.fn(), }; const useUIStore = Object.assign( diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index a2bb2d971..178d66158 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { cardGridProps } from "@/components/shared/cardGrid"; import { SectionHeader } from "@/components/shared/SectionHeader"; import { selectFollowing } from "@/utils/cardInteraction"; @@ -8,7 +8,7 @@ import { useVaultStore } from "@/stores/vaultStore"; import { useTeamStore } from "@/stores/teamStore"; import type { TeamMember } from "@/stores/teamStore"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; -import { useUIStore } from "@/stores/uiStore"; +import { useUIStore, type MembersPanel } from "@/stores/uiStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { useHistoryStore } from "@/stores/historyStore"; import { avatarColor } from "@/components/shared/AvatarStack"; @@ -24,6 +24,8 @@ import { effectivePermissions, hasBuiltinRole, PERM_BITS } from "@/hooks/usePerm import { useBusinessLock } from "@/hooks/useBusinessLock"; import { runTeamAction } from "@/services/teamActionFeedback"; import { TeamRolesPanel } from "@/components/members/panels/RolesPanel"; +import { SecurityPolicyPanel } from "@/components/members/panels/SecurityPolicyPanel"; +import { VAULT_MANAGER_BITS } from "@/services/permissions"; import { guestCapFor, inviteSessionOf, memberHasAccess, seatUsage, sessionDisplayName } from "@/services/teamSharing"; import { RoleToggleChip, roleLabel } from "@/components/members/roleChips"; import { ConvertToTeamGate } from "@/components/vault-share/ConvertToTeamGate"; @@ -57,10 +59,8 @@ export default function MembersPage() { const sortMode = useUIStore((s) => s.membersSortMode); const setLayoutMode = useUIStore((s) => s.setMembersLayoutMode); const setSortMode = useUIStore((s) => s.setMembersSortMode); - const membersInvitePending = useUIStore((s) => s.membersInvitePending); - const clearMembersInvitePending = useUIStore((s) => s.clearMembersInvitePending); - const membersRolesPending = useUIStore((s) => s.membersRolesPending); - const clearMembersRolesPending = useUIStore((s) => s.clearMembersRolesPending); + const membersPanelPending = useUIStore((s) => s.membersPanelPending); + const clearMembersPanelPending = useUIStore((s) => s.clearMembersPanelPending); const openCloudAuth = useUIStore((s) => s.openCloudAuth); const myUserId = useMyUserId(); @@ -68,27 +68,16 @@ export default function MembersPage() { const [primaryVaultId, setPrimaryVaultId] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState([]); - const [showInvitePanel, setShowInvitePanel] = useState(false); - const [showDetailPanel, setShowDetailPanel] = useState(false); - const [showRolesPanel, setShowRolesPanel] = useState(false); + const [panel, setPanel] = useState(null); + const togglePanel = (next: MembersPanel) => setPanel((cur) => (cur === next ? null : next)); + const closeTransientPanel = () => setPanel((cur) => (cur === "detail" || cur === "invite" ? null : cur)); const [offboardingMembers, setOffboardingMembers] = useState(null); useEffect(() => { - if (membersInvitePending) { - setShowInvitePanel(true); - setShowDetailPanel(false); - clearMembersInvitePending(); - } - }, [membersInvitePending, clearMembersInvitePending]); - - useEffect(() => { - if (membersRolesPending) { - setShowRolesPanel(true); - setShowDetailPanel(false); - setShowInvitePanel(false); - clearMembersRolesPending(); - } - }, [membersRolesPending, clearMembersRolesPending]); + if (!membersPanelPending) return; + setPanel(membersPanelPending); + clearMembersPanelPending(); + }, [membersPanelPending, clearMembersPanelPending]); const [detailMemberId, setDetailMemberId] = useState(null); useEffect(() => { @@ -123,6 +112,7 @@ export default function MembersPage() { const canManageMembers = (myEffectivePerms & PERM_BITS.MANAGE_MEMBERS) !== 0; const canNameMembers = canManageMembers && memberNamingSupported(members); const canManageRoles = (myEffectivePerms & PERM_BITS.MANAGE_ROLES) !== 0; + const canManageVault = (myEffectivePerms & VAULT_MANAGER_BITS) !== 0; const canInvite = (myEffectivePerms & PERM_BITS.INVITE_MEMBERS) !== 0; const isOwnerMember = (member: TeamMember) => @@ -188,7 +178,7 @@ export default function MembersPage() { } = useDragSelection(orderedIds); const detailMember = detailMemberId ? members.find((m) => m.user_id === detailMemberId) ?? null : null; - const openDetail = (id: string) => { setDetailMemberId(id); setShowDetailPanel(true); setShowInvitePanel(false); }; + const openDetail = (id: string) => { setDetailMemberId(id); setPanel("detail"); }; const { focusedId } = useListKeyNav({ orderedIds, @@ -199,7 +189,7 @@ export default function MembersPage() { layoutMode, onEnter: openDetail, onEdit: openDetail, - onEscape: () => { setShowDetailPanel(false); setShowInvitePanel(false); }, + onEscape: closeTransientPanel, }); // Sessions this client hosts with the session key still in memory — the only @@ -456,8 +446,8 @@ const vaultTabs = selectedVaultIds.length > 1 sortMode={sortMode} onSortModeChange={setSortMode} canInvite={canPrivateInvite} - showInvitePanel={showInvitePanel} - onToggleInvite={() => { setShowInvitePanel((p) => !p); }} + activePanel={panel} + onTogglePanel={togglePanel} selectedCount={0} vaultTabs={vaultTabs} primaryVaultId={primaryVaultId} @@ -496,11 +486,11 @@ const vaultTabs = selectedVaultIds.length > 1 return ( <> - {showInvitePanel && ( + {panel === "invite" && ( setShowInvitePanel(false)} + onCancel={() => setPanel(null)} onConverted={() => {}} /> )} @@ -532,7 +522,13 @@ const vaultTabs = selectedVaultIds.length > 1 } // ── Team vault ───────────────────────────────────────────────────────────── - const panelOpen = showDetailPanel || showInvitePanel || showRolesPanel; + const panelOpen = panel !== null; + const toolbarPanel = (title: string, icon: string, body: ReactNode) => ( + + setPanel(null)} /> +
{body}
+
+ ); return ( <> @@ -540,7 +536,7 @@ const vaultTabs = selectedVaultIds.length > 1 panelOpen={panelOpen} panelWidth={320} panel={ - showDetailPanel && detailMember + panel === "detail" && detailMember ? ( 1 canNameMembers={canNameMembers} isTargetOwner={isOwnerMember(detailMember)} viewer={myMember} - onClose={() => setShowDetailPanel(false)} + onClose={() => setPanel(null)} onUpdated={reload} /> ) - : showInvitePanel + : panel === "invite" ? ( setShowInvitePanel(false)} + onClose={() => setPanel(null)} onMemberAdded={reload} /> ) - : showRolesPanel && myUserId - ? ( - - setShowRolesPanel(false)} - /> -
- -
-
- ) - : null + : panel === "roles" && myUserId + ? toolbarPanel(t("members.roles"), "lucide:shield", ) + : panel === "security" + ? toolbarPanel(t("members.security.title"), "lucide:shield-check", ) + : null } className="bg-(--t-bg-base)" > @@ -593,12 +580,11 @@ const vaultTabs = selectedVaultIds.length > 1 sortMode={sortMode} onSortModeChange={setSortMode} canInvite={canInvite} - showInvitePanel={showInvitePanel} - onToggleInvite={() => { setShowInvitePanel((p) => !p); setShowDetailPanel(false); setShowRolesPanel(false); }} + activePanel={panel} + onTogglePanel={togglePanel} pendingCount={pendingInvites.length || undefined} canManageRoles={canManageRoles} - showRolesPanel={showRolesPanel} - onToggleRoles={() => { setShowRolesPanel((p) => !p); setShowDetailPanel(false); setShowInvitePanel(false); }} + canManageVault={canManageVault} selectedCount={selectedIdSet.size} vaultTabs={vaultTabs} primaryVaultId={primaryVaultId} @@ -609,7 +595,7 @@ const vaultTabs = selectedVaultIds.length > 1 selectionAreaRef={selectionAreaRef} onMouseDown={handleSelectionAreaMouseDown} dragBox={dragBox} - onClick={() => { setShowDetailPanel(false); setShowInvitePanel(false); }} + onClick={closeTransientPanel} className="flex-1 overflow-y-auto px-9 pt-5 pb-9" >
@@ -667,12 +653,12 @@ const vaultTabs = selectedVaultIds.length > 1 isOwner={isOwnerMember(m)} isSelected={selectedIdSet.has(m.user_id)} isFocused={focusedId === m.user_id} - isEditing={showDetailPanel && detailMemberId === m.user_id} + isEditing={panel === "detail" && detailMemberId === m.user_id} layoutMode={layoutMode} canManage={canActOn(m)} editable={canEditMember(m)} onAddRole={() => openDetail(m.user_id)} - onSelect={selectFollowing(handleItemSelect, showDetailPanel, () => setDetailMemberId(m.user_id))} + onSelect={selectFollowing(handleItemSelect, panel === "detail", () => setDetailMemberId(m.user_id))} onOpen={() => openDetail(m.user_id)} contextMenuItems={buildContextMenuItems(m)} bulkContextMenuItems={selectedIdSet.has(m.user_id) ? bulkContextMenuItems : undefined} diff --git a/src/components/members/MembersToolbar.tsx b/src/components/members/MembersToolbar.tsx index 6e35b437c..dbd9901b8 100644 --- a/src/components/members/MembersToolbar.tsx +++ b/src/components/members/MembersToolbar.tsx @@ -2,6 +2,7 @@ import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; import { ToolbarViewControls } from "@/components/shared/ToolbarViewControls"; import type { LayoutMode, SortMode } from "@/components/shared/ToolbarViewControls"; +import type { MembersPanel } from "@/stores/uiStore"; export interface MembersToolbarProps { search: string; @@ -11,12 +12,11 @@ export interface MembersToolbarProps { sortMode: SortMode; onSortModeChange: (v: SortMode) => void; canInvite: boolean; - showInvitePanel: boolean; - onToggleInvite: () => void; + activePanel: MembersPanel | "detail" | null; + onTogglePanel: (panel: MembersPanel) => void; pendingCount?: number; canManageRoles?: boolean; - showRolesPanel?: boolean; - onToggleRoles?: () => void; + canManageVault?: boolean; selectedCount: number; vaultTabs?: { id: string; name: string }[]; primaryVaultId: string | null; @@ -27,9 +27,9 @@ export function MembersToolbar({ search, onSearchChange, layoutMode, onLayoutModeChange, sortMode, onSortModeChange, - canInvite, showInvitePanel, onToggleInvite, + canInvite, activePanel, onTogglePanel, pendingCount, - canManageRoles, showRolesPanel, onToggleRoles, + canManageRoles, canManageVault, selectedCount, vaultTabs, primaryVaultId, onSelectVault, }: MembersToolbarProps) { @@ -79,34 +79,26 @@ export function MembersToolbar({ )} - {canManageRoles && onToggleRoles && ( - + {canManageVault && ( + onTogglePanel("security")} /> + )} + {canManageRoles && ( + onTogglePanel("roles")} /> )} {canInvite && ( <>
); } + +function PanelButton({ active, icon, label, onClick }: { active: boolean; icon: string; label: string; onClick: () => void }) { + return ( + + ); +} diff --git a/src/components/members/panels/RolesPanel.tsx b/src/components/members/panels/RolesPanel.tsx index ca8ec84cb..c4f9e0b34 100644 --- a/src/components/members/panels/RolesPanel.tsx +++ b/src/components/members/panels/RolesPanel.tsx @@ -8,7 +8,7 @@ import { type Permission, PERM_BITS, effectivePermissions } from "@/hooks/usePer import { PERMISSION_GROUPS, type PermissionGroupKey } from "@/services/permissions"; import { permissionLabel, roleLabel } from "@/components/members/roleChips"; import { CheckboxBox } from "@/components/shared/Checkbox"; -import { UpgradeAction } from "@/components/shared/BusinessLockBanner"; +import { BusinessLockCard } from "@/components/shared/BusinessLockBanner"; import { useBusinessLock } from "@/hooks/useBusinessLock"; // ─── Permission metadata ────────────────────────────────────────────────────── @@ -475,16 +475,7 @@ export function TeamRolesPanel({ teamId, myUserId }: { teamId: string; myUserId: )}
- {locked && ( -
- -
-

{t("shared.businessLock.title")}

-

{t("shared.businessLock.rolesBody")}

-
- -
- )} + {locked && } {!locked && customRoles.length === 0 && (
({ locked: false, setLockPolicy: vi.fn((..._a: unknown[]) => Promise.resolve()) })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/hooks/useBusinessLock", () => ({ useBusinessLock: () => ({ locked: h.locked, isOwner: true }) })); +vi.mock("@/services/teamActionFeedback", () => ({ runTeamAction: ({ run }: { run: () => Promise }) => run() })); +vi.mock("@/services/billingCheckout", () => ({ openBillingCheckout: vi.fn() })); + +import { SecurityPolicyPanel } from "./SecurityPolicyPanel"; +import { useTeamStore } from "@/stores/teamStore"; + +const setTeam = (lock_policy: { max_minutes: number; force_vault: boolean } | null) => + useTeamStore.setState({ + teams: [{ id: "t", name: "Acme", owner_id: "o", owner_tier: "business", created_at: "", role_ids: [], lock_policy }], + setLockPolicy: h.setLockPolicy, + }); + +beforeEach(() => { h.locked = false; h.setLockPolicy.mockClear(); }); +afterEach(cleanup); + +test("turning enforcement on sends a 15-minute policy", () => { + setTeam(null); + render(); + fireEvent.click(screen.getByRole("switch", { name: "members.security.enforce" })); + expect(h.setLockPolicy).toHaveBeenCalledWith("t", { max_minutes: 15, force_vault: false }); +}); + +test("requiring Lock vault keeps the timeout", () => { + setTeam({ max_minutes: 30, force_vault: false }); + render(); + fireEvent.click(screen.getByRole("switch", { name: "members.security.forceVault" })); + expect(h.setLockPolicy).toHaveBeenCalledWith("t", { max_minutes: 30, force_vault: true }); +}); + +test("changing the maximum keeps the vault requirement", () => { + setTeam({ max_minutes: 30, force_vault: true }); + render(); + fireEvent.click(screen.getByRole("button", { name: "members.security.maxLabel" })); + fireEvent.click(screen.getByText("settings.account.sessionSecurity.timeout.5min")); + expect(h.setLockPolicy).toHaveBeenCalledWith("t", { max_minutes: 5, force_vault: true }); + expect(screen.queryByText("settings.account.sessionSecurity.timeout.never")).toBeNull(); +}); + +test("turning enforcement off clears the policy", () => { + setTeam({ max_minutes: 30, force_vault: true }); + render(); + fireEvent.click(screen.getByRole("switch", { name: "members.security.enforce" })); + expect(h.setLockPolicy).toHaveBeenCalledWith("t", null); +}); + +test("below Business with no policy only the upsell card shows", () => { + h.locked = true; + setTeam(null); + render(); + expect(screen.getByText("members.security.lockBody")).toBeTruthy(); + expect(screen.queryByRole("switch")).toBeNull(); +}); + +test("below Business with a policy the controls are frozen and removal is offered", () => { + h.locked = true; + setTeam({ max_minutes: 5, force_vault: false }); + render(); + expect(screen.getByText("members.security.lapsed")).toBeTruthy(); + expect((screen.getByRole("switch", { name: "members.security.enforce" }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText("members.security.remove")).toBeTruthy(); +}); diff --git a/src/components/members/panels/SecurityPolicyPanel.tsx b/src/components/members/panels/SecurityPolicyPanel.tsx new file mode 100644 index 000000000..65f8ca523 --- /dev/null +++ b/src/components/members/panels/SecurityPolicyPanel.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FormSelect } from "@/components/shared/FormSelect"; +import { Toggle } from "@/components/shared/Toggle"; +import { BusinessLapseNotice, BusinessLockCard } from "@/components/shared/BusinessLockBanner"; +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"; + +const DEFAULT_MAX_MINUTES = 15; + +export function SecurityPolicyPanel({ teamId }: { teamId: string }) { + const { t } = useTranslation(); + const policy = useTeamStore((s) => s.teams.find((x) => x.id === teamId)?.lock_policy ?? null); + const setLockPolicy = useTeamStore((s) => s.setLockPolicy); + const { locked } = useBusinessLock(teamId); + const [busy, setBusy] = useState(false); + + const save = async (next: TeamLockPolicy | null) => { + setBusy(true); + try { + await runTeamAction({ + pending: t("members.security.saving"), + success: t("members.security.saved"), + run: () => setLockPolicy(teamId, next), + }); + } catch { + // runTeamAction already reported the failure. + } finally { + setBusy(false); + } + }; + + if (locked && !policy) return ; + + const disabled = busy || locked; + return ( +
+ save(null)} + /> +
+
+

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

+

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

+
+ void save(on ? { max_minutes: DEFAULT_MAX_MINUTES, force_vault: false } : null)} + aria-label={t("members.security.enforce")} + /> +
+ {policy && ( + <> +
+

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

+ o.value !== "never")} + ariaLabel={t("members.security.maxLabel")} + disabled={disabled} + onChange={(value) => void save({ ...policy, max_minutes: Number(value) })} + /> +
+
+
+

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

+

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

+
+ void save({ ...policy, force_vault: on })} + aria-label={t("members.security.forceVault")} + /> +
+ + )} +

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

+
+ ); +} diff --git a/src/components/settings/sections/SessionSecuritySettings.policy.test.tsx b/src/components/settings/sections/SessionSecuritySettings.policy.test.tsx new file mode 100644 index 000000000..e38084dc5 --- /dev/null +++ b/src/components/settings/sections/SessionSecuritySettings.policy.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => (o ? `${k} ${JSON.stringify(o)}` : k), + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/services/appLock", () => ({ systemAuthAvailable: async () => false })); +vi.mock("@/utils/platform", async () => ({ + ...(await vi.importActual("@/utils/platform")), + usePlatform: () => "linux", + useIsAndroid: () => false, +})); + +import { SessionSecuritySettings } from "./SessionSecuritySettings"; +import { useSecurityStore } from "@/stores/securityStore"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; +import { useTeamStore } from "@/stores/teamStore"; + +beforeEach(() => { + useSecurityStore.setState({ sessionTimeoutMinutes: null, lockAction: "screen", systemAuthUnlock: false }); + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 15, forceVault: true } }); + useTeamStore.setState({ + teams: [{ id: "t", name: "Acme", owner_id: "o", owner_tier: "business", created_at: "", role_ids: [], lock_policy: { max_minutes: 15, force_vault: true } }], + }); +}); +afterEach(cleanup); + +test("the policy clamps the shown values, hides looser choices and names the team", () => { + render(); + const autoLock = screen.getByRole("button", { name: "settings.account.sessionSecurity.autoLockLabel" }); + expect(autoLock.textContent).toContain("settings.account.sessionSecurity.timeout.15min"); + fireEvent.click(autoLock); + expect(screen.queryByText("settings.account.sessionSecurity.timeout.never")).toBeNull(); + expect(screen.queryByText("settings.account.sessionSecurity.timeout.30min")).toBeNull(); + expect(screen.getByText("settings.account.sessionSecurity.timeout.5min")).toBeTruthy(); + expect(screen.getByText(/settings\.account\.sessionSecurity\.policy\.timeout .*Acme/)).toBeTruthy(); + expect(screen.getByText(/settings\.account\.sessionSecurity\.policy\.vault .*Acme/)).toBeTruthy(); + const action = screen.getByRole("button", { name: "settings.account.sessionSecurity.lockAction.label" }); + expect(action.textContent).toContain("settings.account.sessionSecurity.lockAction.vault"); +}); + +test("offline, with only the cached policy, the line falls back to your team", () => { + useTeamStore.setState({ teams: [] }); + render(); + expect(screen.getByText(/policy\.timeout .*settings\.account\.sessionSecurity\.policy\.yourTeam/)).toBeTruthy(); +}); + +test("without a policy nothing changes", () => { + useOrgLockPolicyStore.setState({ policy: null }); + render(); + expect(screen.queryByText(/sessionSecurity\.policy\./)).toBeNull(); + expect(screen.getByRole("button", { name: "settings.account.sessionSecurity.autoLockLabel" }).textContent) + .toContain("settings.account.sessionSecurity.timeout.never"); +}); diff --git a/src/components/settings/sections/SessionSecuritySettings.tsx b/src/components/settings/sections/SessionSecuritySettings.tsx index cfcb6fd4a..62eface8c 100644 --- a/src/components/settings/sections/SessionSecuritySettings.tsx +++ b/src/components/settings/sections/SessionSecuritySettings.tsx @@ -7,14 +7,17 @@ import { systemAuthMethodKey } from "@/hooks/useSystemAuthPrompt"; import { useSecurityStore, type LockAction } from "@/stores/securityStore"; import { canLockApp, canLockVault } from "@/utils/accountMode"; import { usePlatform } from "@/utils/platform"; -import { IMMEDIATELY, sessionTimeoutOptions, sessionTimeoutValue } from "@/utils/sessionTimeout"; +import { IMMEDIATELY, sessionTimeoutLabel, sessionTimeoutOptions, sessionTimeoutValue } from "@/utils/sessionTimeout"; +import { useEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings"; +import { policyTeamNames, timeoutAllowed } from "@/services/lockPolicy"; +import { useTeamStore } from "@/stores/teamStore"; export function SessionSecuritySettings({ mode }: { mode: string | null }) { const { t } = useTranslation(); const platform = usePlatform(); - const sessionTimeoutMinutes = useSecurityStore((s) => s.sessionTimeoutMinutes); + const { sessionTimeoutMinutes, lockAction, policy } = useEffectiveLockSettings(); + const teams = useTeamStore((s) => s.teams); const setSessionTimeoutMinutes = useSecurityStore((s) => s.setSessionTimeoutMinutes); - const lockAction = useSecurityStore((s) => s.lockAction); const setLockAction = useSecurityStore((s) => s.setLockAction); const systemAuthUnlock = useSecurityStore((s) => s.systemAuthUnlock); const setSystemAuthUnlock = useSecurityStore((s) => s.setSystemAuthUnlock); @@ -27,8 +30,12 @@ export function SessionSecuritySettings({ mode }: { mode: string | null }) { const effectiveAction: LockAction = vaultLockable ? lockAction : "screen"; const actionOptions = [ ...(vaultLockable ? [{ value: "vault", label: t("settings.account.sessionSecurity.lockAction.vault") }] : []), - { value: "screen", label: t("settings.account.sessionSecurity.lockAction.screen") }, + ...(policy?.forceVault && vaultLockable ? [] : [{ value: "screen", label: t("settings.account.sessionSecurity.lockAction.screen") }]), ]; + const timeoutOptions = sessionTimeoutOptions(t).filter((o) => + timeoutAllowed(o.value === "never" ? null : Number(o.value), policy)); + const names = policy ? policyTeamNames(teams, policy) : null; + const who = (list: string[]) => (list.length ? list.join(", ") : t("settings.account.sessionSecurity.policy.yourTeam")); return (
@@ -36,7 +43,7 @@ export function SessionSecuritySettings({ mode }: { mode: string | null }) {

{t("settings.account.sessionSecurity.autoLockLabel")}

{ @@ -45,6 +52,16 @@ export function SessionSecuritySettings({ mode }: { mode: string | null }) { }} />

{t("settings.account.sessionSecurity.autoLockDesc")}

+ {policy && names && ( +

+ {policy.maxMinutes === IMMEDIATELY + ? t("settings.account.sessionSecurity.policy.timeoutImmediately", { teams: who(names.timeout) }) + : t("settings.account.sessionSecurity.policy.timeout", { + teams: who(names.timeout), + duration: sessionTimeoutLabel(t, sessionTimeoutValue(policy.maxMinutes)), + })} +

+ )}
@@ -61,6 +78,11 @@ export function SessionSecuritySettings({ mode }: { mode: string | null }) { ? "settings.account.sessionSecurity.lockAction.screenDesc" : "settings.account.sessionSecurity.lockAction.vaultDesc")}

+ {policy?.forceVault && names && ( +

+ {t("settings.account.sessionSecurity.policy.vault", { teams: who(names.vault) })} +

+ )} {sessionTimeoutMinutes === IMMEDIATELY && effectiveAction === "vault" && (

{t("settings.account.sessionSecurity.immediateVaultWarning")} diff --git a/src/components/shared/BusinessLockBanner.tsx b/src/components/shared/BusinessLockBanner.tsx index a0175a445..2c7509445 100644 --- a/src/components/shared/BusinessLockBanner.tsx +++ b/src/components/shared/BusinessLockBanner.tsx @@ -71,3 +71,17 @@ export function BusinessLockLine({ teamId, label }: { teamId: string; label: str

); } + +export function BusinessLockCard({ teamId, body }: { teamId: string; body: string }) { + const { t } = useTranslation(); + return ( +
+ +
+

{t("shared.businessLock.title")}

+

{body}

+
+ +
+ ); +} diff --git a/src/components/vault-admin/useVaultAdmin.ts b/src/components/vault-admin/useVaultAdmin.ts index fd6b083e8..f42dd4853 100644 --- a/src/components/vault-admin/useVaultAdmin.ts +++ b/src/components/vault-admin/useVaultAdmin.ts @@ -16,7 +16,7 @@ export function useVaultAdmin( const { t } = useTranslation(); const { teams, rolesByTeam, membersByTeam } = useTeamStore(); const openMembersNav = useUIStore((s) => s.openMembersNav); - const openMembersRoles = useUIStore((s) => s.openMembersRoles); + const openMembersPanel = useUIStore((s) => s.openMembersPanel); const { pos, open: openAtPointer, openAt, close: closeMenu } = useContextMenu(); const [dialog, setDialog] = useState(null); const [shareOpen, setShareOpen] = useState(false); @@ -38,7 +38,8 @@ export function useVaultAdmin( switch (action) { case "share": opts?.onShare ? opts.onShare() : setShareOpen(true); return; case "members": opts?.onActivate?.(); openMembersNav(); return; - case "roles": opts?.onActivate?.(); openMembersRoles(); return; + case "roles": + case "security": opts?.onActivate?.(); openMembersPanel(action); return; case "rename": case "makePrivate": case "delete": diff --git a/src/components/vault-admin/vaultAdminTarget.ts b/src/components/vault-admin/vaultAdminTarget.ts index 0b32e3602..9dfbe5458 100644 --- a/src/components/vault-admin/vaultAdminTarget.ts +++ b/src/components/vault-admin/vaultAdminTarget.ts @@ -1,4 +1,4 @@ -import { PERM_BITS, isTeamOwner } from "@/services/permissions"; +import { VAULT_MANAGER_BITS, isTeamOwner } from "@/services/permissions"; /** What a vault-admin surface acts on. Mirrors `VaultDetail` in VaultsSection. */ export interface VaultAdminTarget { @@ -35,7 +35,7 @@ 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) & (PERM_BITS.MANAGE_VAULT | PERM_BITS.ADMINISTRATOR)) !== 0); + const managesVault = myRoles.some((r) => ((r.permissions ?? 0) & VAULT_MANAGER_BITS) !== 0); return { isTeam, diff --git a/src/components/vault-admin/vaultMenuItems.test.ts b/src/components/vault-admin/vaultMenuItems.test.ts index df83e3128..6af9efbaf 100644 --- a/src/components/vault-admin/vaultMenuItems.test.ts +++ b/src/components/vault-admin/vaultMenuItems.test.ts @@ -38,6 +38,7 @@ test("a team vault owner gets the full menu, destructive last", () => { "layout.vaultMenu.share", "layout.vaultMenu.members", "layout.vaultMenu.roles", + "layout.vaultMenu.security", "layout.vaultMenu.rename", "layout.vaultMenu.makePrivate", "layout.vaultMenu.delete", @@ -98,3 +99,9 @@ test("a team member can leave the vault, as the last and destructive entry", () test("an owner is never offered leave", () => { expect(labels(ownerCaps, 4)).not.toContain("layout.vaultMenu.leave"); }); + +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"); +}); diff --git a/src/components/vault-admin/vaultMenuItems.ts b/src/components/vault-admin/vaultMenuItems.ts index f6b1387c9..23363833d 100644 --- a/src/components/vault-admin/vaultMenuItems.ts +++ b/src/components/vault-admin/vaultMenuItems.ts @@ -2,7 +2,7 @@ import type { ContextMenuItem } from "@/components/shared/ContextMenu"; import type { VaultAdminCapabilities } from "./vaultAdminTarget"; export type VaultMenuAction = - | "share" | "members" | "roles" | "rename" | "makePrivate" | "delete" | "leave"; + | "share" | "members" | "roles" | "security" | "rename" | "makePrivate" | "delete" | "leave"; /** * Built from what the vault is, never from disabled rows: a private vault has no @@ -31,6 +31,9 @@ export function vaultMenuItems({ onClick: () => on("members"), }); items.push({ label: t("layout.vaultMenu.roles"), icon: "lucide:shield", onClick: () => on("roles") }); + if (caps.canRename) { + items.push({ label: t("layout.vaultMenu.security"), icon: "lucide:shield-check", onClick: () => on("security") }); + } } if (caps.canRename) { diff --git a/src/hooks/useEffectiveLockSettings.test.ts b/src/hooks/useEffectiveLockSettings.test.ts new file mode 100644 index 000000000..56fedc879 --- /dev/null +++ b/src/hooks/useEffectiveLockSettings.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +import { beforeEach, expect, test } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useSecurityStore } from "@/stores/securityStore"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; +import { getEffectiveLockSettings, useEffectiveLockSettings } from "./useEffectiveLockSettings"; + +beforeEach(() => { + useSecurityStore.setState({ sessionTimeoutMinutes: null, lockAction: "screen" }); + useOrgLockPolicyStore.setState({ policy: null }); +}); + +test("without a policy the member's own settings apply", () => { + expect(getEffectiveLockSettings()).toEqual({ sessionTimeoutMinutes: null, lockAction: "screen", policy: null }); +}); + +test("a policy clamps without rewriting the stored preference", () => { + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 15, forceVault: true } }); + const { result } = renderHook(() => useEffectiveLockSettings()); + expect(result.current.sessionTimeoutMinutes).toBe(15); + expect(result.current.lockAction).toBe("vault"); + expect(useSecurityStore.getState().sessionTimeoutMinutes).toBeNull(); + expect(useSecurityStore.getState().lockAction).toBe("screen"); +}); diff --git a/src/hooks/useEffectiveLockSettings.ts b/src/hooks/useEffectiveLockSettings.ts new file mode 100644 index 000000000..ac963814d --- /dev/null +++ b/src/hooks/useEffectiveLockSettings.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import { effectiveLockAction, effectiveTimeout, type LockPolicy } from "@/services/lockPolicy"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; +import { useSecurityStore, type LockAction } from "@/stores/securityStore"; + +export interface EffectiveLockSettings { + sessionTimeoutMinutes: number | null; + lockAction: LockAction; + policy: LockPolicy | null; +} + +function resolve(minutes: number | null, action: LockAction, policy: LockPolicy | null): EffectiveLockSettings { + return { + sessionTimeoutMinutes: effectiveTimeout(minutes, policy), + lockAction: effectiveLockAction(action, policy), + policy, + }; +} + +export function getEffectiveLockSettings(): EffectiveLockSettings { + const { sessionTimeoutMinutes, lockAction } = useSecurityStore.getState(); + return resolve(sessionTimeoutMinutes, lockAction, useOrgLockPolicyStore.getState().policy); +} + +export function useEffectiveLockSettings(): EffectiveLockSettings { + const minutes = useSecurityStore((s) => s.sessionTimeoutMinutes); + const action = useSecurityStore((s) => s.lockAction); + const policy = useOrgLockPolicyStore((s) => s.policy); + return useMemo(() => resolve(minutes, action, policy), [minutes, action, policy]); +} diff --git a/src/hooks/useSessionExpiration.test.ts b/src/hooks/useSessionExpiration.test.ts index c3e5df3f6..10355519c 100644 --- a/src/hooks/useSessionExpiration.test.ts +++ b/src/hooks/useSessionExpiration.test.ts @@ -28,6 +28,7 @@ vi.mock("@tauri-apps/api/window", () => ({ import { useSessionExpiration } from "./useSessionExpiration"; import { useSecurityStore } from "@/stores/securityStore"; import { useAppLockStore } from "@/stores/appLockStore"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; function setVisibility(state: "visible" | "hidden") { Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); @@ -43,6 +44,7 @@ beforeEach(() => { h.resized = null; useAppLockStore.setState({ kind: null }); useSecurityStore.setState({ sessionTimeoutMinutes: null, lockAction: "vault", systemAuthUnlock: false }); + useOrgLockPolicyStore.setState({ policy: null }); }); afterEach(() => { cleanup(); @@ -183,3 +185,10 @@ test("Immediately on an account that cannot lock leaves recents alone", async () await mount(); expect(h.hideInRecents).not.toHaveBeenCalled(); }); + +test("a policy timeout applies when the member chose Never", async () => { + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 15, forceVault: false } }); + await mount(); + await act(async () => { await vi.advanceTimersByTimeAsync(15 * 60_000 + 5_000); }); + expect(h.lockApp).toHaveBeenCalled(); +}); diff --git a/src/hooks/useSessionExpiration.ts b/src/hooks/useSessionExpiration.ts index 56a4e9431..1ff92c6f2 100644 --- a/src/hooks/useSessionExpiration.ts +++ b/src/hooks/useSessionExpiration.ts @@ -3,6 +3,7 @@ import { getAccountMode } from "@/services/account"; import { lockApp, setHideInRecents } from "@/services/appLock"; import { isLeaveLockSuppressed } from "@/services/leaveLockSuppression"; import { useAppLockStore } from "@/stores/appLockStore"; +import { useEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings"; import { useSecurityStore } from "@/stores/securityStore"; import { canLockApp } from "@/utils/accountMode"; import { IMMEDIATELY } from "@/utils/sessionTimeout"; @@ -12,7 +13,7 @@ const IMMEDIATE_IDLE_MINUTES = 5; const ACTIVITY_EVENTS = ["pointerdown", "mousemove", "keydown", "touchstart"] as const; export function useSessionExpiration(ready = true): void { - const sessionTimeoutMinutes = useSecurityStore((s) => s.sessionTimeoutMinutes); + const { sessionTimeoutMinutes } = useEffectiveLockSettings(); const systemAuthUnlock = useSecurityStore((s) => s.systemAuthUnlock); useEffect(() => { diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index e04e6c942..e4b8c6a37 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -122,6 +122,9 @@ "handleManaged": "Přezdívky na tomto serveru spravuje vaše organizace.", "failedToCreateTeam": "Tým se nepodařilo vytvořit: {{status}}", "failedToRenameTeam": "Tým se nepodařilo přejmenovat: {{status}}", + "failedToListTeams": "Týmy se nepodařilo načíst: {{status}}", + "failedToSaveLockPolicy": "Zásadu zamykání se nepodařilo uložit: {{status}}", + "lockPolicyServerTooOld": "Pro zásady zamykání aktualizujte svůj server Voltius.", "failedToDeleteTeam": "Tým se nepodařilo smazat: {{status}}", "failedToListMembers": "Seznam členů se nepodařilo načíst: {{status}}", "failedToListVaultKeyHolders": "Seznam držitelů klíče trezoru se nepodařilo načíst: {{status}}", diff --git a/src/i18n/locales/cs/layout.json b/src/i18n/locales/cs/layout.json index b729b0cb5..62497abd8 100644 --- a/src/i18n/locales/cs/layout.json +++ b/src/i18n/locales/cs/layout.json @@ -256,6 +256,7 @@ "share": "Sdílet…", "members": "Členové", "roles": "Role", + "security": "Zásady zabezpečení…", "rename": "Přejmenovat…", "makePrivate": "Nastavit jako soukromý…", "delete": "Smazat trezor…", diff --git a/src/i18n/locales/cs/members.json b/src/i18n/locales/cs/members.json index a95354a14..640d2742a 100644 --- a/src/i18n/locales/cs/members.json +++ b/src/i18n/locales/cs/members.json @@ -321,6 +321,21 @@ "editor": "Editor", "member": "Člen", "connect-only": "Jen připojení" + }, + "security": { + "button": "Zabezpečení", + "title": "Zásady zabezpečení", + "enforce": "Vynutit automatické zamčení", + "enforceDesc": "Členové si mohou zvolit kratší dobu, nikdy delší.", + "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.", + "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.", + "remove": "Odebrat zásadu", + "saving": "Ukládání zásady…", + "saved": "Zásada uložena" } } } diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 7b4fbe38c..c9e3046e4 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -249,6 +249,12 @@ }, "needsSystemAuth": "Pro zamykání účtu bez hlavního hesla zapněte systémové ověření.", "immediateVaultWarning": "Se zámkem trezoru i krátké opuštění Voltiusu zavře otevřené relace. Zámek obrazovky je nechá běžet.", + "policy": { + "timeout": "Určuje {{teams}}: nejvýše {{duration}}.", + "timeoutImmediately": "Určuje {{teams}}: zamknout ihned po opuštění Voltius.", + "vault": "Určuje {{teams}}: je vyžadováno „Zamknout trezor“.", + "yourTeam": "váš tým" + }, "lockAction": { "label": "Při zamknutí", "vault": "Zamknout trezor (zavřít relace)", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a4ce0e337..9bf40ab8a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -122,6 +122,9 @@ "handleManaged": "Your organization manages handles on this server.", "failedToCreateTeam": "Failed to create team: {{status}}", "failedToRenameTeam": "Failed to rename team: {{status}}", + "failedToListTeams": "Failed to list teams: {{status}}", + "failedToSaveLockPolicy": "Failed to save the lock policy: {{status}}", + "lockPolicyServerTooOld": "Update your Voltius server to use lock policies.", "failedToDeleteTeam": "Failed to delete team: {{status}}", "failedToListMembers": "Failed to list members: {{status}}", "failedToListVaultKeyHolders": "Failed to list vault key holders: {{status}}", diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index 432edf74f..1041878bb 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -248,6 +248,7 @@ "share": "Share…", "members": "Members", "roles": "Roles", + "security": "Security policy…", "rename": "Rename…", "makePrivate": "Make private…", "delete": "Delete vault…", diff --git a/src/i18n/locales/en/members.json b/src/i18n/locales/en/members.json index 41ae4e89c..ba84eb9f5 100644 --- a/src/i18n/locales/en/members.json +++ b/src/i18n/locales/en/members.json @@ -301,6 +301,21 @@ "editor": "Editor", "member": "Member", "connect-only": "Connect-only" + }, + "security": { + "button": "Security", + "title": "Security policy", + "enforce": "Enforce auto-lock", + "enforceDesc": "Members can choose a shorter time, never a longer one.", + "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.", + "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.", + "remove": "Remove policy", + "saving": "Saving lock policy…", + "saved": "Lock policy saved" } } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 78d943f95..f5623e242 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -267,7 +267,13 @@ } }, "needsSystemAuth": "Turn on system authentication to lock an account without a master password.", - "immediateVaultWarning": "With Lock vault, leaving Voltius even briefly closes your open sessions. Lock screen keeps them running." + "immediateVaultWarning": "With Lock vault, leaving Voltius even briefly closes your open sessions. Lock screen keeps them running.", + "policy": { + "timeout": "Set by {{teams}}: at most {{duration}}.", + "timeoutImmediately": "Set by {{teams}}: lock as soon as you leave Voltius.", + "vault": "Set by {{teams}}: Lock vault is required.", + "yourTeam": "your team" + } }, "email": "Email", "editEmail": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 4d8e9778a..5d72e1efa 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -122,6 +122,9 @@ "handleManaged": "Votre organisation gère les identifiants sur ce serveur.", "failedToCreateTeam": "Échec de la création de l'équipe : {{status}}", "failedToRenameTeam": "Échec du renommage de l'équipe : {{status}}", + "failedToListTeams": "Échec du chargement des équipes : {{status}}", + "failedToSaveLockPolicy": "Échec de l'enregistrement de la règle de verrouillage : {{status}}", + "lockPolicyServerTooOld": "Mettez à jour votre serveur Voltius pour utiliser les règles de verrouillage.", "failedToDeleteTeam": "Échec de la suppression de l'équipe : {{status}}", "failedToListMembers": "Échec du chargement des membres : {{status}}", "failedToListVaultKeyHolders": "Échec du chargement des détenteurs de clé de coffre : {{status}}", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index 3e8ec1e87..2dac6363a 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -252,6 +252,7 @@ "share": "Partager…", "members": "Membres", "roles": "Rôles", + "security": "Règle de sécurité…", "rename": "Renommer…", "makePrivate": "Rendre privé…", "delete": "Supprimer le coffre…", diff --git a/src/i18n/locales/fr/members.json b/src/i18n/locales/fr/members.json index b1b82fdb6..d42db268b 100644 --- a/src/i18n/locales/fr/members.json +++ b/src/i18n/locales/fr/members.json @@ -309,6 +309,21 @@ "editor": "Éditeur", "member": "Membre", "connect-only": "Connexion seule" + }, + "security": { + "button": "Sécurité", + "title": "Règle de sécurité", + "enforce": "Imposer le verrouillage automatique", + "enforceDesc": "Les membres peuvent choisir un délai plus court, jamais plus long.", + "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.", + "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.", + "remove": "Supprimer la règle", + "saving": "Enregistrement de la règle…", + "saved": "Règle enregistrée" } } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index ea976044d..a43fd2451 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -249,6 +249,12 @@ }, "needsSystemAuth": "Activez l'authentification système pour verrouiller un compte sans mot de passe principal.", "immediateVaultWarning": "Avec « Verrouiller le coffre », quitter Voltius même un instant ferme vos sessions ouvertes. « Verrouiller l'écran » les garde actives.", + "policy": { + "timeout": "Imposé par {{teams}} : au plus {{duration}}.", + "timeoutImmediately": "Imposé par {{teams}} : verrouillage dès que vous quittez Voltius.", + "vault": "Imposé par {{teams}} : « Verrouiller le coffre » obligatoire.", + "yourTeam": "votre équipe" + }, "lockAction": { "label": "Au verrouillage", "vault": "Verrouiller le coffre (fermer les sessions)", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index b7dbcf696..593b452c3 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -122,6 +122,9 @@ "handleManaged": "На этом сервере псевдонимами управляет ваша организация.", "failedToCreateTeam": "Не удалось создать команду: {{status}}", "failedToRenameTeam": "Не удалось переименовать команду: {{status}}", + "failedToListTeams": "Не удалось загрузить список команд: {{status}}", + "failedToSaveLockPolicy": "Не удалось сохранить политику блокировки: {{status}}", + "lockPolicyServerTooOld": "Обновите сервер Voltius, чтобы использовать политики блокировки.", "failedToDeleteTeam": "Не удалось удалить команду: {{status}}", "failedToListMembers": "Не удалось получить список участников: {{status}}", "failedToListVaultKeyHolders": "Не удалось получить список владельцев ключа хранилища: {{status}}", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index beacbe370..1881dc473 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -256,6 +256,7 @@ "share": "Поделиться…", "members": "Участники", "roles": "Роли", + "security": "Политика безопасности…", "rename": "Переименовать…", "makePrivate": "Сделать личным…", "delete": "Удалить хранилище…", diff --git a/src/i18n/locales/ru/members.json b/src/i18n/locales/ru/members.json index 7858be7a9..d377ebb8c 100644 --- a/src/i18n/locales/ru/members.json +++ b/src/i18n/locales/ru/members.json @@ -317,6 +317,21 @@ "editor": "Редактор", "member": "Участник", "connect-only": "Только подключение" + }, + "security": { + "button": "Безопасность", + "title": "Политика безопасности", + "enforce": "Требовать автоблокировку", + "enforceDesc": "Участники могут выбрать меньшее время, но не большее.", + "maxLabel": "Блокировать не позже чем через", + "forceVault": "Требовать «Заблокировать хранилище»", + "forceVaultDesc": "Участники не смогут выбрать блокировку экрана, поэтому блокировка всегда удаляет ключ хранилища из памяти.", + "appliesToAll": "Действует для всех участников, включая вас. Старые версии Voltius её не применяют.", + "lockBody": "Политики блокировки доступны в тарифе Business.", + "lapsed": "Тариф Business истёк: политика продолжает действовать. Её можно удалить.", + "remove": "Удалить политику", + "saving": "Сохранение политики…", + "saved": "Политика сохранена" } } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index ff6675fb8..16a2ea516 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -249,6 +249,12 @@ }, "needsSystemAuth": "Включите системную аутентификацию, чтобы блокировать учётную запись без мастер-пароля.", "immediateVaultWarning": "С блокировкой хранилища даже краткий выход из Voltius закрывает открытые сессии. Блокировка экрана сохраняет их.", + "policy": { + "timeout": "Политика {{teams}}: максимум {{duration}}.", + "timeoutImmediately": "Политика {{teams}}: блокировка сразу при выходе из Voltius.", + "vault": "Политика {{teams}}: требуется «Заблокировать хранилище».", + "yourTeam": "вашей команды" + }, "lockAction": { "label": "При блокировке", "vault": "Заблокировать хранилище (закрыть сессии)", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 7b833812b..ad29e13ff 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -122,6 +122,9 @@ "handleManaged": "Bu sunucuda kullanıcı adlarını kuruluşunuz yönetiyor.", "failedToCreateTeam": "Ekip oluşturulamadı: {{status}}", "failedToRenameTeam": "Ekip yeniden adlandırılamadı: {{status}}", + "failedToListTeams": "Ekipler listelenemedi: {{status}}", + "failedToSaveLockPolicy": "Kilit politikası kaydedilemedi: {{status}}", + "lockPolicyServerTooOld": "Kilit politikalarını kullanmak için Voltius sunucunuzu güncelleyin.", "failedToDeleteTeam": "Ekip silinemedi: {{status}}", "failedToListMembers": "Üyeler listelenemedi: {{status}}", "failedToListVaultKeyHolders": "Kasa anahtarına sahip üyeler listelenemedi: {{status}}", diff --git a/src/i18n/locales/tr/layout.json b/src/i18n/locales/tr/layout.json index 273796dc3..75bee68e5 100644 --- a/src/i18n/locales/tr/layout.json +++ b/src/i18n/locales/tr/layout.json @@ -248,6 +248,7 @@ "share": "Paylaş…", "members": "Üyeler", "roles": "Roller", + "security": "Güvenlik politikası…", "rename": "Yeniden adlandır…", "makePrivate": "Özel yap…", "delete": "Kasayı sil…", diff --git a/src/i18n/locales/tr/members.json b/src/i18n/locales/tr/members.json index f84f99b8e..c52eb0307 100644 --- a/src/i18n/locales/tr/members.json +++ b/src/i18n/locales/tr/members.json @@ -305,6 +305,21 @@ "editor": "Düzenleyici", "member": "Üye", "connect-only": "Yalnızca bağlantı" + }, + "security": { + "button": "Güvenlik", + "title": "Güvenlik politikası", + "enforce": "Otomatik kilidi zorunlu kıl", + "enforceDesc": "Üyeler daha kısa bir süre seçebilir, asla daha uzun değil.", + "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.", + "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.", + "remove": "Politikayı kaldır", + "saving": "Politika kaydediliyor…", + "saved": "Politika kaydedildi" } } } diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 5f0e53e8e..efc69e02f 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -255,6 +255,12 @@ }, "needsSystemAuth": "Ana parolası olmayan bir hesabı kilitlemek için sistem kimlik doğrulamasını açın.", "immediateVaultWarning": "Kasayı kilitle seçiliyken Voltius'tan kısa süre ayrılmak bile açık oturumlarınızı kapatır. Ekranı kilitle onları çalışır tutar.", + "policy": { + "timeout": "{{teams}} ilkesi: en fazla {{duration}}.", + "timeoutImmediately": "{{teams}} ilkesi: Voltius'tan çıkınca hemen kilitle.", + "vault": "{{teams}} ilkesi: \"Kasayı kilitle\" zorunlu.", + "yourTeam": "Ekibinizin" + }, "lockAction": { "label": "Kilitlerken", "vault": "Kasayı kilitle (oturumları kapat)", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 53c86ae5d..18cbd832c 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -122,6 +122,9 @@ "handleManaged": "此服务器上的用户名由你的组织管理。", "failedToCreateTeam": "创建团队失败:{{status}}", "failedToRenameTeam": "重命名团队失败:{{status}}", + "failedToListTeams": "加载团队列表失败:{{status}}", + "failedToSaveLockPolicy": "保存锁定策略失败:{{status}}", + "lockPolicyServerTooOld": "请更新 Voltius 服务器以使用锁定策略。", "failedToDeleteTeam": "删除团队失败:{{status}}", "failedToListMembers": "获取成员列表失败:{{status}}", "failedToListVaultKeyHolders": "获取保险库密钥持有者列表失败:{{status}}", diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json index d1a15d613..772068b4c 100644 --- a/src/i18n/locales/zh/layout.json +++ b/src/i18n/locales/zh/layout.json @@ -248,6 +248,7 @@ "share": "共享…", "members": "成员", "roles": "角色", + "security": "安全策略…", "rename": "重命名…", "makePrivate": "设为私有…", "delete": "删除保险库…", diff --git a/src/i18n/locales/zh/members.json b/src/i18n/locales/zh/members.json index 4204bd9f1..48b862b6a 100644 --- a/src/i18n/locales/zh/members.json +++ b/src/i18n/locales/zh/members.json @@ -301,6 +301,21 @@ "editor": "编辑者", "member": "成员", "connect-only": "仅连接" + }, + "security": { + "button": "安全", + "title": "安全策略", + "enforce": "强制自动锁定", + "enforceDesc": "成员可以选择更短的时间,但不能更长。", + "maxLabel": "最长锁定时间", + "forceVault": "要求“锁定保险库”", + "forceVaultDesc": "成员无法选择锁定屏幕,因此锁定时总会从内存中移除保险库密钥。", + "appliesToAll": "适用于所有成员,包括你。旧版 Voltius 不会执行此策略。", + "lockBody": "锁定策略需要团队的 Business 方案。", + "lapsed": "Business 方案已失效:此策略仍然生效,你可以将其移除。", + "remove": "移除策略", + "saving": "正在保存锁定策略…", + "saved": "锁定策略已保存" } } } diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 57409c5d2..eaad6aa88 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -143,6 +143,12 @@ }, "needsSystemAuth": "开启系统身份验证后,才能锁定没有主密码的账户。", "immediateVaultWarning": "选择“锁定保险库”时,即使短暂离开 Voltius 也会关闭已打开的会话。“锁定屏幕”会让它们继续运行。", + "policy": { + "timeout": "{{teams}} 的策略:最长 {{duration}}。", + "timeoutImmediately": "{{teams}} 的策略:离开 Voltius 时立即锁定。", + "vault": "{{teams}} 的策略:必须使用“锁定保险库”。", + "yourTeam": "你的团队" + }, "lockAction": { "label": "锁定方式", "vault": "锁定保险库(关闭会话)", diff --git a/src/i18n/lockPolicyStrings.test.ts b/src/i18n/lockPolicyStrings.test.ts new file mode 100644 index 000000000..b758ddc9d --- /dev/null +++ b/src/i18n/lockPolicyStrings.test.ts @@ -0,0 +1,17 @@ +// @vitest-environment jsdom +import { afterEach, expect, test } from "vitest"; +import i18n from "./index"; + +const key = "settings.account.sessionSecurity.policy.timeout"; +const label = (k: string) => i18n.t(`settings.account.sessionSecurity.timeout.${k}`); + +afterEach(async () => { await i18n.changeLanguage("en"); }); + +test("several teams read as one source, not a subject that needs agreement", () => { + expect(i18n.t(key, { teams: "Acme, Ops", duration: label("15min") })).toBe("Set by Acme, Ops: at most 15 minutes."); +}); + +test("Czech keeps the duration label in the form the label already has", async () => { + await i18n.changeLanguage("cs"); + expect(i18n.t(key, { teams: "Acme", duration: label("1h") })).toBe("Určuje Acme: nejvýše 1 hodina."); +}); diff --git a/src/services/appLock.test.ts b/src/services/appLock.test.ts index bfb035489..dc3f8fbfd 100644 --- a/src/services/appLock.test.ts +++ b/src/services/appLock.test.ts @@ -20,6 +20,7 @@ import { lockApp, systemAuthVerify } from "./appLock"; import { isLeaveLockSuppressed, withLeaveLockSuppressed } from "./leaveLockSuppression"; import { useSecurityStore } from "@/stores/securityStore"; import { useAppLockStore } from "@/stores/appLockStore"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; beforeEach(() => { vi.clearAllMocks(); @@ -27,6 +28,7 @@ beforeEach(() => { h.mode = "local"; useSecurityStore.setState({ lockAction: "vault", systemAuthUnlock: false }); useAppLockStore.setState({ kind: null }); + useOrgLockPolicyStore.setState({ policy: null }); Object.defineProperty(window, "location", { value: { reload: h.reload }, writable: true, configurable: true }); }); @@ -129,3 +131,11 @@ test("suppression ends on its own when the wrapped work never settles", async () expect(isLeaveLockSuppressed()).toBe(false); vi.useRealTimers(); }); + +test("a forced Lock vault policy locks the vault even when the member chose Lock screen", async () => { + useSecurityStore.setState({ lockAction: "screen" }); + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 15, forceVault: true } }); + await lockApp(); + expect(h.lockVaultSession).toHaveBeenCalled(); + expect(useAppLockStore.getState().kind).toBeNull(); +}); diff --git a/src/services/appLock.ts b/src/services/appLock.ts index 1dccfb44e..3c31437e1 100644 --- a/src/services/appLock.ts +++ b/src/services/appLock.ts @@ -1,5 +1,6 @@ import { invoke } from "@/lib/invoke"; import { getAccountMode, lockVaultSession } from "@/services/account"; +import { getEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings"; import { useAppLockStore } from "@/stores/appLockStore"; import { useSecurityStore } from "@/stores/securityStore"; import { canLockApp, canLockVault } from "@/utils/accountMode"; @@ -18,7 +19,8 @@ export function systemAuthVerify(reason: string): Promise { } export async function lockApp(): Promise { - const { lockAction, systemAuthUnlock } = useSecurityStore.getState(); + const { systemAuthUnlock } = useSecurityStore.getState(); + const { lockAction } = getEffectiveLockSettings(); const mode = await getAccountMode().catch(() => null); if (!canLockApp(mode, systemAuthUnlock)) return; if (!canLockVault(mode) && !(await systemAuthAvailable())) return; diff --git a/src/services/lockPolicy.test.ts b/src/services/lockPolicy.test.ts new file mode 100644 index 000000000..d31ea7a2d --- /dev/null +++ b/src/services/lockPolicy.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; +import { + combineLockPolicies, effectiveLockAction, effectiveTimeout, policyTeamNames, timeoutAllowed, +} from "./lockPolicy"; + +const team = (name: string, lock_policy: { max_minutes: number; force_vault: boolean } | null | undefined) => + ({ name, lock_policy }); + +describe("combineLockPolicies", () => { + test("no team has a policy", () => { + expect(combineLockPolicies([])).toBeNull(); + expect(combineLockPolicies([team("A", null), team("B", undefined)])).toBeNull(); + }); + + test("strictest timeout and any forced vault win", () => { + expect(combineLockPolicies([ + team("A", { max_minutes: 30, force_vault: false }), + team("B", null), + team("C", { max_minutes: 15, force_vault: true }), + ])).toEqual({ maxMinutes: 15, forceVault: true }); + }); + + test("Immediately beats any number", () => { + expect(combineLockPolicies([ + team("A", { max_minutes: 5, force_vault: false }), + team("B", { max_minutes: 0, force_vault: false }), + ])).toEqual({ maxMinutes: 0, forceVault: false }); + }); +}); + +describe("effectiveTimeout", () => { + const p = { maxMinutes: 15, forceVault: false }; + test.each([ + [null, null, null], + [30, null, 30], + [null, p, 15], + [30, p, 15], + [5, p, 5], + [0, p, 0], + ])("own %s with policy %o → %s", (own, policy, want) => { + expect(effectiveTimeout(own, policy)).toBe(want); + }); +}); + +test("effectiveLockAction forces vault only when the policy says so", () => { + expect(effectiveLockAction("screen", null)).toBe("screen"); + expect(effectiveLockAction("screen", { maxMinutes: 15, forceVault: false })).toBe("screen"); + expect(effectiveLockAction("screen", { maxMinutes: 15, forceVault: true })).toBe("vault"); +}); + +test("timeoutAllowed hides Never and anything longer than the max", () => { + const p = { maxMinutes: 15, forceVault: false }; + expect(timeoutAllowed(null, null)).toBe(true); + expect(timeoutAllowed(null, p)).toBe(false); + expect(timeoutAllowed(30, p)).toBe(false); + expect(timeoutAllowed(15, p)).toBe(true); + expect(timeoutAllowed(0, p)).toBe(true); +}); + +test("policyTeamNames names the teams that set the binding rules", () => { + const teams = [ + team("Acme", { max_minutes: 15, force_vault: false }), + team("Ops", { max_minutes: 30, force_vault: true }), + team("Free", null), + ]; + expect(policyTeamNames(teams, { maxMinutes: 15, forceVault: true })).toEqual({ timeout: ["Acme"], vault: ["Ops"] }); +}); diff --git a/src/services/lockPolicy.ts b/src/services/lockPolicy.ts new file mode 100644 index 000000000..71dadcbc3 --- /dev/null +++ b/src/services/lockPolicy.ts @@ -0,0 +1,41 @@ +import type { Team } from "@/services/teamService"; +import type { LockAction } from "@/stores/securityStore"; + +export interface LockPolicy { + maxMinutes: number; + forceVault: boolean; +} + +export function combineLockPolicies(teams: Pick[]): LockPolicy | null { + let out: LockPolicy | null = null; + for (const { lock_policy: p } of teams) { + if (!p) continue; + out = out + ? { maxMinutes: Math.min(out.maxMinutes, p.max_minutes), forceVault: out.forceVault || p.force_vault } + : { maxMinutes: p.max_minutes, forceVault: p.force_vault }; + } + return out; +} + +export function effectiveTimeout(own: number | null, policy: LockPolicy | null): number | null { + if (!policy) return own; + return own === null ? policy.maxMinutes : Math.min(own, policy.maxMinutes); +} + +export function effectiveLockAction(own: LockAction, policy: LockPolicy | null): LockAction { + return policy?.forceVault ? "vault" : own; +} + +export function timeoutAllowed(minutes: number | null, policy: LockPolicy | null): boolean { + return !policy || (minutes !== null && minutes <= policy.maxMinutes); +} + +export function policyTeamNames( + teams: Pick[], + policy: LockPolicy, +): { timeout: string[]; vault: string[] } { + return { + timeout: teams.filter((t) => t.lock_policy?.max_minutes === policy.maxMinutes).map((t) => t.name), + vault: teams.filter((t) => t.lock_policy?.force_vault).map((t) => t.name), + }; +} diff --git a/src/services/permissions.ts b/src/services/permissions.ts index f7143ac2d..dc61dc87c 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -50,6 +50,9 @@ export const PERM_BITS: Record = { ADMINISTRATOR: 1 << 18, // 262144 }; +// Same check as the server's `require_vault_manager`. +export const VAULT_MANAGER_BITS = PERM_BITS.MANAGE_VAULT | PERM_BITS.ADMINISTRATOR; + export const ALL_PERMISSION_BITS = Object.values(PERM_BITS).reduce((a, b) => a | b, 0); export const OBJECT_RULE_BITS = PERM_BITS.VIEW | PERM_BITS.CONNECT | PERM_BITS.VIEW_SECRETS diff --git a/src/services/teamService.lockPolicy.test.ts b/src/services/teamService.lockPolicy.test.ts new file mode 100644 index 000000000..7b691786d --- /dev/null +++ b/src/services/teamService.lockPolicy.test.ts @@ -0,0 +1,50 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ invoke: vi.fn(), appFetch: vi.fn(), loadTeams: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); +vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@/stores/teamStore", () => ({ useTeamStore: { getState: () => ({ loadTeams: h.loadTeams }) } })); + +import { setTeamLockPolicy } from "./teamService"; + +function jwt(): string { + const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${b64({ alg: "HS256" })}.${b64({ exp: Math.floor(Date.now() / 1000) + 3600, sub: "me" })}.sig`; +} +const status = (s: number) => ({ ok: s >= 200 && s < 300, status: s, json: async () => ({}) }); + +beforeEach(() => { + Object.values(h).forEach((m) => m.mockReset()); + h.invoke.mockImplementation(async (cmd: string, args: { key: string }) => + cmd === "keychain_get" ? ({ jwt: jwt(), server_url: "https://s" } as Record)[args.key] ?? null : null); +}); + +test("a policy is PUT and null is a DELETE", async () => { + h.appFetch.mockResolvedValue(status(204)); + await setTeamLockPolicy("t1", { max_minutes: 15, force_vault: true }); + let [url, init] = h.appFetch.mock.calls[0]; + expect(url).toBe("https://s/v1/teams/t1/lock-policy"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body)).toEqual({ max_minutes: 15, force_vault: true }); + + await setTeamLockPolicy("t1", null); + [url, init] = h.appFetch.mock.calls[1]; + expect(url).toBe("https://s/v1/teams/t1/lock-policy"); + expect(init.method).toBe("DELETE"); +}); + +test("402 becomes the plan-required error", async () => { + h.appFetch.mockResolvedValue(status(402)); + await expect(setTeamLockPolicy("t1", { max_minutes: 15, force_vault: false })).rejects.toMatchObject({ status: 402 }); +}); + +test("404 tells the admin to update the server", async () => { + h.appFetch.mockResolvedValue(status(404)); + await expect(setTeamLockPolicy("t1", null)).rejects.toThrow("common.error.lockPolicyServerTooOld"); +}); + +test("other failures carry the status", async () => { + h.appFetch.mockResolvedValue(status(500)); + await expect(setTeamLockPolicy("t1", null)).rejects.toThrow("common.error.failedToSaveLockPolicy"); +}); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 3676f4e24..834eac357 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -6,6 +6,11 @@ import { getJwt, getServerUrl } from "@/services/authTokens"; // ─── Types ──────────────────────────────────────────────────────────────────── +export interface TeamLockPolicy { + max_minutes: number; + force_vault: boolean; +} + export interface Team { id: string; name: string; @@ -15,6 +20,7 @@ export interface Team { role_ids: string[]; permission_allow?: number; permission_deny?: number; + lock_policy?: TeamLockPolicy | null; } export type CreatedTeam = Pick; @@ -77,11 +83,23 @@ export async function renameTeam(teamId: string, name: string): Promise { if (!res.ok) throw new Error(i18n.t("common.error.failedToRenameTeam", { status: res.status })); } +export async function setTeamLockPolicy(teamId: string, policy: TeamLockPolicy | null): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth( + `${serverUrl}/v1/teams/${teamId}/lock-policy`, + policy ? { method: "PUT", body: JSON.stringify(policy) } : { method: "DELETE" }, + ); + refuseIfPlanRequired(res); + if (res.status === 404) throw new Error(i18n.t("common.error.lockPolicyServerTooOld")); + if (!res.ok) throw new Error(i18n.t("common.error.failedToSaveLockPolicy", { status: res.status })); +} + export async function listTeams(): Promise { const serverUrl = await getServerUrl(); if (!serverUrl) return []; const res = await fetchAuth(`${serverUrl}/v1/teams`); - if (!res.ok) return []; + if (!res.ok) throw new Error(i18n.t("common.error.failedToListTeams", { status: res.status })); return res.json(); } diff --git a/src/services/teamService.wrappers.test.ts b/src/services/teamService.wrappers.test.ts index 91b88c3cc..4cc9c50c8 100644 --- a/src/services/teamService.wrappers.test.ts +++ b/src/services/teamService.wrappers.test.ts @@ -101,6 +101,11 @@ describe("request shaping", () => { }); describe("error mapping", () => { + test("listTeams rejects on a server error instead of reporting no teams", async () => { + connected(); + h.appFetch.mockResolvedValueOnce(err(500)); + await expect(listTeams()).rejects.toThrow("common.error.failedToListTeams"); + }); test("addMember 404 → userNotFoundVoltiusAccount, other → generic", async () => { connected(); h.appFetch.mockResolvedValueOnce(err(404)); diff --git a/src/stores/orgLockPolicyStore.ts b/src/stores/orgLockPolicyStore.ts new file mode 100644 index 000000000..ccc0ffcbd --- /dev/null +++ b/src/stores/orgLockPolicyStore.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { LockPolicy } from "@/services/lockPolicy"; + +interface OrgLockPolicyStore { + policy: LockPolicy | null; + setPolicy: (policy: LockPolicy | null) => void; +} + +export const useOrgLockPolicyStore = create()( + persist( + (set) => ({ + policy: null, + setPolicy: (policy) => set({ policy }), + }), + { name: "voltius-org-lock-policy", partialize: (s) => ({ policy: s.policy }) }, + ), +); diff --git a/src/stores/persistedAccountUiState.ts b/src/stores/persistedAccountUiState.ts index c0bb8acd6..ee54853a5 100644 --- a/src/stores/persistedAccountUiState.ts +++ b/src/stores/persistedAccountUiState.ts @@ -43,6 +43,8 @@ export const ACCOUNT_SCOPED_STORAGE_KEYS = [ "voltius-pending-team-secret-upload", // Which identity this account chose per shared host: ids only, never secrets. "voltius-identity-picks", + // The combined team lock policy, so it applies before the first team fetch. + "voltius-org-lock-policy", ]; /** diff --git a/src/stores/teamStore.lockPolicy.test.ts b/src/stores/teamStore.lockPolicy.test.ts new file mode 100644 index 000000000..cbc03e79e --- /dev/null +++ b/src/stores/teamStore.lockPolicy.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { beforeEach, expect, test, vi } from "vitest"; + +const api = vi.hoisted(() => ({ listTeams: vi.fn() })); +vi.mock("@/services/teamService", async (orig) => ({ ...(await orig()), listTeams: api.listTeams })); + +import { useTeamStore } from "./teamStore"; +import { useOrgLockPolicyStore } from "./orgLockPolicyStore"; + +const row = (id: string, lock_policy: { max_minutes: number; force_vault: boolean } | null) => ({ + id, name: id, owner_id: "o", owner_tier: "business", created_at: "", role_ids: [], permission_allow: 0, permission_deny: 0, lock_policy, +}); + +beforeEach(() => { + useTeamStore.setState({ teams: [], rolesByTeam: {} }); + useOrgLockPolicyStore.setState({ policy: null }); + api.listTeams.mockReset(); +}); + +test("a successful fetch stores the combined policy", async () => { + api.listTeams.mockResolvedValue([row("a", { max_minutes: 30, force_vault: false }), row("b", { max_minutes: 15, force_vault: true })]); + await useTeamStore.getState().loadTeams(); + expect(useOrgLockPolicyStore.getState().policy).toEqual({ maxMinutes: 15, forceVault: true }); +}); + +test("a fetch with no policy clears it", async () => { + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 5, forceVault: false } }); + api.listTeams.mockResolvedValue([row("a", null)]); + await useTeamStore.getState().loadTeams(); + expect(useOrgLockPolicyStore.getState().policy).toBeNull(); +}); + +test("a failed fetch keeps the cached policy", async () => { + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 5, forceVault: true } }); + api.listTeams.mockRejectedValue(new Error("500")); + await useTeamStore.getState().loadTeams(); + expect(useOrgLockPolicyStore.getState().policy).toEqual({ maxMinutes: 5, forceVault: true }); +}); + +test("a policy-only change is not swallowed by the unchanged-list shortcut", async () => { + api.listTeams.mockResolvedValue([row("a", null)]); + await useTeamStore.getState().loadTeams(); + api.listTeams.mockResolvedValue([row("a", { max_minutes: 15, force_vault: false })]); + await useTeamStore.getState().loadTeams(); + expect(useTeamStore.getState().teams[0].lock_policy).toEqual({ max_minutes: 15, force_vault: false }); + expect(useOrgLockPolicyStore.getState().policy).toEqual({ maxMinutes: 15, forceVault: false }); +}); diff --git a/src/stores/teamStore.ts b/src/stores/teamStore.ts index 83f7f2f2c..7d02d4adc 100644 --- a/src/stores/teamStore.ts +++ b/src/stores/teamStore.ts @@ -3,8 +3,10 @@ import { invoke } from "@/lib/invoke"; import * as api from "@/services/teamService"; import { logFailure } from "@/lib/logger"; import { effectivePermissions } from "@/services/permissions"; +import { combineLockPolicies } from "@/services/lockPolicy"; +import { useOrgLockPolicyStore } from "@/stores/orgLockPolicyStore"; import { isBusinessLocked } from "@/stores/subscriptionTier"; -import type { CreatedTeam, Team, TeamMember, TeamRole, PendingInvitation, MyPendingInvitation } from "@/services/teamService"; +import type { CreatedTeam, Team, TeamLockPolicy, TeamMember, TeamRole, PendingInvitation, MyPendingInvitation } from "@/services/teamService"; export type { Team, TeamMember, TeamRole, PendingInvitation, MyPendingInvitation }; interface TeamStore { @@ -20,6 +22,7 @@ interface TeamStore { self: { userId: string; online: boolean } | null; loadTeams: () => Promise; + setLockPolicy: (teamId: string, policy: TeamLockPolicy | null) => Promise; createTeam: (name: string) => Promise; loadMembers: (teamId: string) => Promise; addMember: (teamId: string, email: string, role?: string) => Promise; @@ -109,9 +112,11 @@ export const useTeamStore = create()( t.owner_tier === prev[i].owner_tier && t.permission_allow === prev[i].permission_allow && t.permission_deny === prev[i].permission_deny && + JSON.stringify(t.lock_policy ?? null) === JSON.stringify(prev[i].lock_policy ?? null) && JSON.stringify(t.role_ids) === JSON.stringify(prev[i].role_ids)); const teams = same ? prev : fresh; set({ teams, loading: false }); + useOrgLockPolicyStore.getState().setPolicy(combineLockPolicies(teams)); if (teams.length > 0 && !get().activeTeamId) { set({ activeTeamId: teams[0].id }); } @@ -126,6 +131,11 @@ export const useTeamStore = create()( } }, + setLockPolicy: async (teamId, policy) => { + await api.setTeamLockPolicy(teamId, policy); + await get().loadTeams(); + }, + createTeam: async (name) => { const created = await api.createTeam(name); // The create reply lacks role_ids and owner_tier; the listed row carries them. diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 3ce2be8f3..96dcc3fd0 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -80,6 +80,8 @@ export type CloudAuthMode = "signin" | "register"; /** `link` moves the current local account to the cloud; `add` signs in to another one beside it. */ export type CloudAuthPurpose = "link" | "add"; +export type MembersPanel = "invite" | "roles" | "security"; + interface UIStore { sidebarOpen: boolean; homeView: boolean; @@ -180,12 +182,9 @@ interface UIStore { setKnownHostsLayoutMode: (v: LayoutMode) => void; snippetsPendingAction: SnippetsPendingAction; setSnippetsPendingAction: (action: SnippetsPendingAction) => void; - membersInvitePending: boolean; - openMembersInvite: () => void; - clearMembersInvitePending: () => void; - membersRolesPending: boolean; - openMembersRoles: () => void; - clearMembersRolesPending: () => void; + membersPanelPending: MembersPanel | null; + openMembersPanel: (panel: MembersPanel) => void; + clearMembersPanelPending: () => void; openMembersNav: () => void; vaultSharePending: boolean; openVaultSharePending: () => void; @@ -245,8 +244,7 @@ export const useUIStore = create()( snippetsLayoutMode: "list" as LayoutMode, knownHostsLayoutMode: "grid" as LayoutMode, snippetsPendingAction: null as SnippetsPendingAction, - membersInvitePending: false, - membersRolesPending: false, + membersPanelPending: null, vaultSharePending: false, whatsNewOpen: false, lastSeenChangelogVersion: null as string | null, @@ -313,10 +311,8 @@ export const useUIStore = create()( setSnippetsLayoutMode: (v) => setPref({ snippetsLayoutMode: v }), setKnownHostsLayoutMode: (v) => setPref({ knownHostsLayoutMode: v }), setSnippetsPendingAction: (action) => set({ snippetsPendingAction: action }), - openMembersInvite: () => set({ activeNav: "members", homeView: false, membersInvitePending: true }), - clearMembersInvitePending: () => set({ membersInvitePending: false }), - openMembersRoles: () => set({ activeNav: "members", homeView: false, membersRolesPending: true }), - clearMembersRolesPending: () => set({ membersRolesPending: false }), + openMembersPanel: (panel) => set({ activeNav: "members", homeView: false, membersPanelPending: panel }), + clearMembersPanelPending: () => set({ membersPanelPending: null }), openMembersNav: () => set({ activeNav: "members", homeView: false }), openVaultSharePending: () => set({ vaultSharePending: true }), clearVaultSharePending: () => set({ vaultSharePending: false }),