From d40296b75a9157699ade64378415477d38fe695b Mon Sep 17 00:00:00 2001 From: kipavy Date: Thu, 8 Oct 2026 10:38:16 +0000 Subject: [PATCH 1/8] feat(lock): combine team lock policies into one strictest policy --- src/services/lockPolicy.test.ts | 67 +++++++++++++++++++++++++++++++++ src/services/lockPolicy.ts | 41 ++++++++++++++++++++ src/services/teamService.ts | 6 +++ 3 files changed, 114 insertions(+) create mode 100644 src/services/lockPolicy.test.ts create mode 100644 src/services/lockPolicy.ts 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/teamService.ts b/src/services/teamService.ts index 3676f4e24..0d604a698 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; From 1b6c16d007a01a064e06623229bd5b6dd1818061 Mon Sep 17 00:00:00 2001 From: kipavy Date: Thu, 8 Oct 2026 10:42:05 +0000 Subject: [PATCH 2/8] feat(lock): cache the team lock policy per account after each team fetch --- src/i18n/locales/cs/common.json | 1 + src/i18n/locales/en/common.json | 1 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/ru/common.json | 1 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/zh/common.json | 1 + src/services/teamService.ts | 2 +- src/services/teamService.wrappers.test.ts | 5 +++ src/stores/orgLockPolicyStore.ts | 18 +++++++++ src/stores/persistedAccountUiState.ts | 2 + src/stores/teamStore.lockPolicy.test.ts | 47 +++++++++++++++++++++++ src/stores/teamStore.ts | 4 ++ 12 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/stores/orgLockPolicyStore.ts create mode 100644 src/stores/teamStore.lockPolicy.test.ts diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index e04e6c942..2b507a105 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -122,6 +122,7 @@ "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}}", "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/en/common.json b/src/i18n/locales/en/common.json index a4ce0e337..ff466900d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -122,6 +122,7 @@ "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}}", "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/fr/common.json b/src/i18n/locales/fr/common.json index 4d8e9778a..c1221d10d 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -122,6 +122,7 @@ "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}}", "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/ru/common.json b/src/i18n/locales/ru/common.json index b7dbcf696..dd947c1df 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -122,6 +122,7 @@ "handleManaged": "На этом сервере псевдонимами управляет ваша организация.", "failedToCreateTeam": "Не удалось создать команду: {{status}}", "failedToRenameTeam": "Не удалось переименовать команду: {{status}}", + "failedToListTeams": "Не удалось загрузить список команд: {{status}}", "failedToDeleteTeam": "Не удалось удалить команду: {{status}}", "failedToListMembers": "Не удалось получить список участников: {{status}}", "failedToListVaultKeyHolders": "Не удалось получить список владельцев ключа хранилища: {{status}}", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 7b833812b..c21deebe1 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -122,6 +122,7 @@ "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}}", "failedToDeleteTeam": "Ekip silinemedi: {{status}}", "failedToListMembers": "Üyeler listelenemedi: {{status}}", "failedToListVaultKeyHolders": "Kasa anahtarına sahip üyeler listelenemedi: {{status}}", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 53c86ae5d..532e8d575 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -122,6 +122,7 @@ "handleManaged": "此服务器上的用户名由你的组织管理。", "failedToCreateTeam": "创建团队失败:{{status}}", "failedToRenameTeam": "重命名团队失败:{{status}}", + "failedToListTeams": "加载团队列表失败:{{status}}", "failedToDeleteTeam": "删除团队失败:{{status}}", "failedToListMembers": "获取成员列表失败:{{status}}", "failedToListVaultKeyHolders": "获取保险库密钥持有者列表失败:{{status}}", diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 0d604a698..25293f5c0 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -87,7 +87,7 @@ 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..c30771e7b 100644 --- a/src/stores/teamStore.ts +++ b/src/stores/teamStore.ts @@ -3,6 +3,8 @@ 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"; export type { Team, TeamMember, TeamRole, PendingInvitation, MyPendingInvitation }; @@ -109,9 +111,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 }); } From 54c0946ad54852dd111168bd4faea6cd2a536033 Mon Sep 17 00:00:00 2001 From: kipavy Date: Thu, 8 Oct 2026 10:50:22 +0000 Subject: [PATCH 3/8] feat(lock): apply the team lock policy to auto-lock and the lock action --- .../layout/SidebarAccountButton.tsx | 3 +- src/hooks/useEffectiveLockSettings.test.ts | 24 +++++++++++++++ src/hooks/useEffectiveLockSettings.ts | 30 +++++++++++++++++++ src/hooks/useSessionExpiration.test.ts | 9 ++++++ src/hooks/useSessionExpiration.ts | 3 +- src/services/appLock.test.ts | 10 +++++++ src/services/appLock.ts | 4 ++- 7 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useEffectiveLockSettings.test.ts create mode 100644 src/hooks/useEffectiveLockSettings.ts 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/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/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; From 6c454bf89d37c5ed59ed79bef185423c5688c561 Mon Sep 17 00:00:00 2001 From: kipavy Date: Thu, 8 Oct 2026 11:05:42 +0000 Subject: [PATCH 4/8] feat(lock): set and clear a team's lock policy from the client --- src/i18n/locales/cs/common.json | 2 + src/i18n/locales/en/common.json | 2 + src/i18n/locales/fr/common.json | 2 + src/i18n/locales/ru/common.json | 2 + src/i18n/locales/tr/common.json | 2 + src/i18n/locales/zh/common.json | 2 + src/services/teamService.lockPolicy.test.ts | 50 +++++++++++++++++++++ src/services/teamService.ts | 12 +++++ src/stores/teamStore.ts | 8 +++- 9 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/services/teamService.lockPolicy.test.ts diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index 2b507a105..e4b8c6a37 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -123,6 +123,8 @@ "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/en/common.json b/src/i18n/locales/en/common.json index ff466900d..9bf40ab8a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -123,6 +123,8 @@ "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/fr/common.json b/src/i18n/locales/fr/common.json index c1221d10d..5d72e1efa 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -123,6 +123,8 @@ "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/ru/common.json b/src/i18n/locales/ru/common.json index dd947c1df..593b452c3 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -123,6 +123,8 @@ "failedToCreateTeam": "Не удалось создать команду: {{status}}", "failedToRenameTeam": "Не удалось переименовать команду: {{status}}", "failedToListTeams": "Не удалось загрузить список команд: {{status}}", + "failedToSaveLockPolicy": "Не удалось сохранить политику блокировки: {{status}}", + "lockPolicyServerTooOld": "Обновите сервер Voltius, чтобы использовать политики блокировки.", "failedToDeleteTeam": "Не удалось удалить команду: {{status}}", "failedToListMembers": "Не удалось получить список участников: {{status}}", "failedToListVaultKeyHolders": "Не удалось получить список владельцев ключа хранилища: {{status}}", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index c21deebe1..ad29e13ff 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -123,6 +123,8 @@ "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/zh/common.json b/src/i18n/locales/zh/common.json index 532e8d575..18cbd832c 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -123,6 +123,8 @@ "failedToCreateTeam": "创建团队失败:{{status}}", "failedToRenameTeam": "重命名团队失败:{{status}}", "failedToListTeams": "加载团队列表失败:{{status}}", + "failedToSaveLockPolicy": "保存锁定策略失败:{{status}}", + "lockPolicyServerTooOld": "请更新 Voltius 服务器以使用锁定策略。", "failedToDeleteTeam": "删除团队失败:{{status}}", "failedToListMembers": "获取成员列表失败:{{status}}", "failedToListVaultKeyHolders": "获取保险库密钥持有者列表失败:{{status}}", 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 25293f5c0..834eac357 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -83,6 +83,18 @@ 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 []; diff --git a/src/stores/teamStore.ts b/src/stores/teamStore.ts index c30771e7b..7d02d4adc 100644 --- a/src/stores/teamStore.ts +++ b/src/stores/teamStore.ts @@ -6,7 +6,7 @@ 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 { @@ -22,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; @@ -130,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. From 1a1eca83a3deae807959d24949a8ae3b43737c02 Mon Sep 17 00:00:00 2001 From: kipavy Date: Thu, 8 Oct 2026 11:43:20 +0000 Subject: [PATCH 5/8] refactor(members): one side-panel state instead of a flag per panel --- .../layout/VaultHeader.menu.test.tsx | 6 +- src/components/layout/VaultHeader.tsx | 4 +- .../layout/VaultRailShareTarget.test.tsx | 2 +- .../members/MembersPage.BulkActions.test.tsx | 4 +- .../MembersPage.InviteToSession.test.tsx | 4 +- .../members/MembersPage.RolesPending.test.tsx | 18 +++-- .../members/MembersPage.SelfCard.test.tsx | 2 +- src/components/members/MembersPage.tsx | 71 ++++++++----------- src/components/members/MembersToolbar.tsx | 27 ++++--- src/components/vault-admin/useVaultAdmin.ts | 4 +- src/stores/uiStore.ts | 20 +++--- 11 files changed, 70 insertions(+), 92 deletions(-) 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..b15809813 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -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"; @@ -57,10 +57,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 +66,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(() => { @@ -188,7 +175,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 +186,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 +443,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 +483,11 @@ const vaultTabs = selectedVaultIds.length > 1 return ( <> - {showInvitePanel && ( + {panel === "invite" && ( setShowInvitePanel(false)} + onCancel={() => setPanel(null)} onConverted={() => {}} /> )} @@ -532,7 +519,7 @@ const vaultTabs = selectedVaultIds.length > 1 } // ── Team vault ───────────────────────────────────────────────────────────── - const panelOpen = showDetailPanel || showInvitePanel || showRolesPanel; + const panelOpen = panel !== null; return ( <> @@ -540,7 +527,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 + : panel === "roles" && myUserId ? ( setShowRolesPanel(false)} + onClose={() => setPanel(null)} />
@@ -593,12 +580,10 @@ 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); }} selectedCount={selectedIdSet.size} vaultTabs={vaultTabs} primaryVaultId={primaryVaultId} @@ -609,7 +594,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 +652,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..3e6df37fa 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,10 @@ 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; selectedCount: number; vaultTabs?: { id: string; name: string }[]; primaryVaultId: string | null; @@ -27,9 +26,9 @@ export function MembersToolbar({ search, onSearchChange, layoutMode, onLayoutModeChange, sortMode, onSortModeChange, - canInvite, showInvitePanel, onToggleInvite, + canInvite, activePanel, onTogglePanel, pendingCount, - canManageRoles, showRolesPanel, onToggleRoles, + canManageRoles, selectedCount, vaultTabs, primaryVaultId, onSelectVault, }: MembersToolbarProps) { @@ -79,14 +78,14 @@ export function MembersToolbar({ )} - {canManageRoles && onToggleRoles && ( + {canManageRoles && ( + onTogglePanel("roles")} /> )} {canInvite && ( @@ -124,3 +117,20 @@ export function MembersToolbar({
); } + +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/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 c235f010e..f42dd4853 100644 --- a/src/components/vault-admin/useVaultAdmin.ts +++ b/src/components/vault-admin/useVaultAdmin.ts @@ -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?.(); openMembersPanel(action); 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/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/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/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/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/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/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/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