diff --git a/src/components/logs/AuditEventRow.tsx b/src/components/logs/AuditEventRow.tsx index c8f2b913d..d02af5b09 100644 --- a/src/components/logs/AuditEventRow.tsx +++ b/src/components/logs/AuditEventRow.tsx @@ -19,10 +19,11 @@ const fallbackResource = () => i18n.t("logs.eventLabels.fallbackResource"); const fallbackHost = () => i18n.t("logs.eventLabels.fallbackHost"); export const ACTION_META: Record = { - "member.invited": { icon: "lucide:user-plus", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberInvited", { name: l.target_name ?? l.target_id ?? fallbackUser() }) }, - "member.joined": { icon: "lucide:user-check", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberJoined", { role: l.metadata?.role ?? fallbackRole() }) }, - "member.removed": { icon: "lucide:user-minus", color: "#ef4444", label: (l) => i18n.t("logs.eventLabels.memberRemoved", { name: l.target_name ?? l.target_id ?? fallbackMember() }) }, - "member.role_changed": { icon: "lucide:user-cog", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberRoleChanged", { name: l.target_name ?? l.target_id ?? fallbackMember() }) }, + "member.invited": { icon: "lucide:user-plus", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberInvited", { name: l.target_name ?? l.target_id ?? fallbackUser() }) }, + "member.joined": { icon: "lucide:user-check", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberJoined", { role: l.metadata?.role ?? fallbackRole() }) }, + "member.removed": { icon: "lucide:user-minus", color: "#ef4444", label: (l) => i18n.t("logs.eventLabels.memberRemoved", { name: l.target_name ?? l.target_id ?? fallbackMember() }) }, + "member.role_changed": { icon: "lucide:user-cog", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberRoleChanged", { name: l.target_name ?? l.target_id ?? fallbackMember() }) }, + "member.permissions_changed": { icon: "lucide:sliders-horizontal", color: "#3b82f6", label: (l) => i18n.t("logs.eventLabels.memberPermissionsChanged", { name: l.target_name ?? l.target_id ?? fallbackMember() }) }, "vault.created": { icon: "lucide:database", color: "#8b5cf6", label: (l) => i18n.t("logs.eventLabels.vaultCreated", { name: l.target_name ?? l.target_id ?? "" }) }, "vault.deleted": { icon: "lucide:database", color: "#ef4444", label: (l) => i18n.t("logs.eventLabels.vaultDeleted", { name: l.target_name ?? l.target_id ?? "" }) }, "vault.renamed": { icon: "lucide:database", color: "#8b5cf6", label: (l) => i18n.t("logs.eventLabels.vaultRenamed", { name: l.target_name ?? l.target_id ?? "" }) }, diff --git a/src/components/logs/AuditFilters.tsx b/src/components/logs/AuditFilters.tsx index 589a66c5a..426319fde 100644 --- a/src/components/logs/AuditFilters.tsx +++ b/src/components/logs/AuditFilters.tsx @@ -12,11 +12,12 @@ import { getAuditTimeRange, type AuditTimeRange } from "./auditLogToolbarUtils"; function getActionOptions(t: TFunction) { return [ { value: "", label: t("logs.filters.actionOptions.all") }, - { value: "member.invited", label: t("logs.filters.actionOptions.memberInvited") }, - { value: "member.joined", label: t("logs.filters.actionOptions.memberJoined") }, - { value: "member.removed", label: t("logs.filters.actionOptions.memberRemoved") }, - { value: "member.role_changed", label: t("logs.filters.actionOptions.memberRoleChanged") }, - { value: "connection.created", label: t("logs.filters.actionOptions.connectionCreated") }, + { value: "member.invited", label: t("logs.filters.actionOptions.memberInvited") }, + { value: "member.joined", label: t("logs.filters.actionOptions.memberJoined") }, + { value: "member.removed", label: t("logs.filters.actionOptions.memberRemoved") }, + { value: "member.role_changed", label: t("logs.filters.actionOptions.memberRoleChanged") }, + { value: "member.permissions_changed", label: t("logs.filters.actionOptions.memberPermissionsChanged") }, + { value: "connection.created", label: t("logs.filters.actionOptions.connectionCreated") }, { value: "connection.updated", label: t("logs.filters.actionOptions.connectionUpdated") }, { value: "connection.deleted", label: t("logs.filters.actionOptions.connectionDeleted") }, { value: "identity.created", label: t("logs.filters.actionOptions.identityCreated") }, diff --git a/src/components/members/MembersPage.MemberDetailPanel.test.tsx b/src/components/members/MembersPage.MemberDetailPanel.test.tsx index dc37beb70..387c7305c 100644 --- a/src/components/members/MembersPage.MemberDetailPanel.test.tsx +++ b/src/components/members/MembersPage.MemberDetailPanel.test.tsx @@ -1,6 +1,7 @@ import { test, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-library/react"; import type { TeamMember, TeamRole } from "@/stores/teamStore"; +import { PERM_BITS } from "@/services/permissions"; const h = vi.hoisted(() => ({ assign: vi.fn(), @@ -9,6 +10,8 @@ const h = vi.hoisted(() => ({ addMemberById: vi.fn(), loadMembers: vi.fn(), push: vi.fn(), + setPerms: vi.fn(), + rotate: vi.fn(), })); vi.mock("react-i18next", () => ({ @@ -29,11 +32,13 @@ vi.mock("@/components/settings/sections/RolesSection", () => ({ })); vi.mock("@/stores/teamStore", () => { const state = { + membersByTeam: {} as Record, assignMemberRole: h.assign, removeMemberRole: h.remove, removeMember: h.removeMember, addMemberById: h.addMemberById, loadMembers: h.loadMembers, + setMemberPermissions: h.setPerms, }; const useTeamStore = Object.assign( (sel: (s: typeof state) => unknown) => sel(state), @@ -53,8 +58,14 @@ vi.mock("@/stores/historyStore", () => ({ vi.mock("@/services/teamActionFeedback", () => ({ runTeamAction: async (o: { run: () => Promise }) => o.run(), })); +vi.mock("@/services/teamKeyRotation", () => ({ + checkAndRotateTeamKey: (...a: unknown[]) => h.rotate(...a), +})); import { MemberDetailPanel } from "./panels/MemberDetailPanel"; +import { useTeamStore } from "@/stores/teamStore"; + +const mockStore = useTeamStore.getState() as unknown as { membersByTeam: Record }; const baseMember: TeamMember = { team_id: "t1", @@ -90,6 +101,9 @@ beforeEach(() => { h.removeMember.mockResolvedValue(undefined); h.addMemberById.mockResolvedValue(undefined); h.loadMembers.mockResolvedValue(undefined); + h.setPerms.mockResolvedValue(undefined); + h.rotate.mockResolvedValue(undefined); + mockStore.membersByTeam = {}; baseProps.onClose = vi.fn(); baseProps.onUpdated = vi.fn(); }); @@ -196,3 +210,344 @@ test("remove undo closure: re-adds member, reassigns each snapshot role, reloads expect(h.addMemberById.mock.invocationCallOrder[0]).toBeLessThan(h.assign.mock.invocationCallOrder[0]); expect(h.assign.mock.invocationCallOrder[0]).toBeLessThan(h.loadMembers.mock.invocationCallOrder[0]); }); + +// ── Permission overrides ────────────────────────────────────────────────── + +const viewerRole: TeamRole = { + id: "r-viewer", team_id: "t1", name: "viewer-role", is_builtin: false, + permissions: PERM_BITS.MANAGE_MEMBERS, position: 0, created_at: "", +}; +const targetRole: TeamRole = { + id: "r-target", team_id: "t1", name: "target-role", is_builtin: false, + permissions: 0, position: 2, created_at: "", +}; +const highRole: TeamRole = { + id: "r-high", team_id: "t1", name: "high-role", is_builtin: false, + permissions: 0, position: 0, created_at: "", +}; + +const viewerMember: TeamMember = { + team_id: "t1", user_id: "uviewer", handle: "me", public_key: "k", + invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", + role_ids: ["r-viewer"], permission_allow: 0, permission_deny: 0, +}; + +const targetMember: TeamMember = { + team_id: "t1", user_id: "u2", handle: "alice", public_key: "k", + invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", + role_ids: ["r-target"], permission_allow: 0, permission_deny: 0, +}; + +function permProps(overrides: Partial<{ + member: TeamMember; isMe: boolean; teamRoles: TeamRole[]; + canManageMembers: boolean; isTargetOwner: boolean; viewer: TeamMember; + onUpdated: () => void; +}> = {}) { + return { + member: targetMember, + isMe: false, + teamId: "t1", + teamRoles: [viewerRole, targetRole], + canManageMembers: true, + isTargetOwner: false, + viewer: viewerMember, + onClose: vi.fn(), + onUpdated: vi.fn(), + ...overrides, + }; +} + +test("permission overrides: renders one row per permission and sends the new masks", async () => { + render(); + + expect(screen.getAllByRole("radiogroup")).toHaveLength(16); + + const row = screen.getByRole("radiogroup", { name: "members.permission.EDIT_KEYS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + + await waitFor(() => + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, PERM_BITS.EDIT_KEYS), + ); + + mockStore.membersByTeam = { t1: [targetMember] }; + + const entry = h.push.mock.calls[0][0] as { undo: () => Promise }; + await entry.undo(); + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, 0); +}); + +test("permission overrides: undo throws instead of writing empty masks when the member is gone from the store", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.EDIT_KEYS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + await waitFor(() => + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, PERM_BITS.EDIT_KEYS), + ); + + const entry = h.push.mock.calls[0][0] as { undo: () => Promise }; + await expect(entry.undo()).rejects.toThrow(); +}); + +// An older server omits both mask fields entirely; every row would otherwise +// render live and 404 on click. +test("no permissions section at all when the server serves neither mask", () => { + const legacy = { ...targetMember }; + delete legacy.permission_allow; + delete legacy.permission_deny; + render(); + + expect(screen.queryAllByRole("radiogroup")).toHaveLength(0); + expect(screen.queryByText("members.permissions.title")).toBeNull(); +}); + +test("undo re-reads the masks so a concurrent change survives the full replace", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.EDIT_KEYS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + await waitFor(() => + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, PERM_BITS.EDIT_KEYS), + ); + + mockStore.membersByTeam = { + t1: [{ ...targetMember, permission_deny: PERM_BITS.EDIT_KEYS | PERM_BITS.CONNECT }], + }; + + const entry = h.push.mock.calls[0][0] as { undo: () => Promise }; + await entry.undo(); + + expect(h.setPerms).toHaveBeenLastCalledWith("t1", "u2", 0, PERM_BITS.CONNECT); +}); + +// CREATE_CUSTOM_ROLES is retired and normally hidden, but it is inside the +// server's ALL_PERMISSIONS, so a mask carrying it must stay clearable. +test("a retired bit set in a mask renders an enabled row that can clear it", async () => { + const member = { ...targetMember, permission_allow: PERM_BITS.CREATE_CUSTOM_ROLES }; + render(); + + expect(screen.getByText("members.permissions.readOnlyNotHeld")).toBeTruthy(); + const row = screen.getByRole("radiogroup", { name: "members.permission.CREATE_CUSTOM_ROLES" }); + expect((within(row).getByRole("radio", { name: /inherit/i }) as HTMLButtonElement).disabled).toBe(false); + + fireEvent.click(within(row).getByRole("radio", { name: /inherit/i })); + + await waitFor(() => expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, 0)); +}); + +test("the retired bit renders no row when neither mask carries it", () => { + render(); + + expect(screen.queryByRole("radiogroup", { name: "members.permission.CREATE_CUSTOM_ROLES" })).toBeNull(); +}); + +test("a notHeld lock enables exactly the offending row, not the others", async () => { + const member = { ...targetMember, permission_allow: PERM_BITS.CONNECT }; + render(); + + const connectRow = screen.getByRole("radiogroup", { name: "members.permission.CONNECT" }); + expect((within(connectRow).getByRole("radio", { name: /allow/i }) as HTMLButtonElement).disabled).toBe(false); + expect((within(connectRow).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(false); + + const otherRow = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + expect((within(otherRow).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(within(connectRow).getByRole("radio", { name: /inherit/i })); + + // Clearing the member's only source of CONNECT crosses the vault key gate, + // so this routes through the confirmation dialog before writing. + fireEvent.click(await screen.findByRole("button", { name: "members.revokeKeyAccess.confirm" })); + + await waitFor(() => + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, 0), + ); +}); + +test.each([ + { + label: "no manage-members permission", + key: "members.permissions.readOnlyNoManage", + overrides: { canManageMembers: false }, + }, + { + label: "target is an owner", + key: "members.permissions.readOnlyOwner", + overrides: { isTargetOwner: true }, + }, + { + label: "target is the viewer themselves", + key: "members.permissions.readOnlySelf", + overrides: { isMe: true }, + }, + { + label: "target holds a role at or above the viewer's", + key: "members.permissions.readOnlyHigherRole", + overrides: { member: { ...targetMember, role_ids: ["r-high"] }, teamRoles: [viewerRole, highRole] }, + }, + { + label: "target already carries an allow bit the viewer lacks", + key: "members.permissions.readOnlyNotHeld", + overrides: { member: { ...targetMember, permission_allow: PERM_BITS.CONNECT } }, + }, +])("read-only reason: $label", ({ overrides, key }) => { + render(); + + expect(screen.getByText(key)).toBeTruthy(); + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + expect((within(row).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(true); +}); + +test("hierarchy: a roleless target is not read-only", () => { + render(); + + expect(screen.queryByText("members.permissions.readOnlyHigherRole")).toBeNull(); + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + expect((within(row).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(false); +}); + +// The hierarchy check must fail CLOSED (read-only) when the viewer side of the +// comparison cannot be resolved at all, not just when it loses the comparison. +test.each([ + { + label: "viewer is undefined", + overrides: { viewer: undefined }, + }, + { + label: "viewer holds no resolvable role", + overrides: { viewer: { ...viewerMember, role_ids: [] } }, + }, +])("hierarchy fails closed when $label", ({ overrides }) => { + render(); + + expect(screen.getByText("members.permissions.readOnlyHigherRole")).toBeTruthy(); + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + expect((within(row).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(true); +}); + +test("choosing allow on a bit the viewer lacks sends no request", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.CONNECT" }); + fireEvent.click(within(row).getByRole("radio", { name: /allow/i })); + + expect(await screen.findByText("members.permissions.readOnlyNotHeld")).toBeTruthy(); + expect(h.setPerms).not.toHaveBeenCalled(); +}); + +// ── Vault key gate confirmation ──────────────────────────────────────────── + +const keyRole: TeamRole = { + id: "r-key", team_id: "t1", name: "key-role", is_builtin: false, + permissions: PERM_BITS.VIEW_SECRETS, position: 2, created_at: "", +}; +const keyMember: TeamMember = { ...targetMember, role_ids: ["r-key"] }; + +test("a gate-crossing change opens the dialog and writes nothing yet", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + + expect(await screen.findByText("members.revokeKeyAccess.title")).toBeTruthy(); + expect(h.setPerms).not.toHaveBeenCalled(); +}); + +test("every row is inert while the revoke dialog is open", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + await screen.findByText("members.revokeKeyAccess.title"); + + const otherRow = screen.getByRole("radiogroup", { name: "members.permission.EDIT_KEYS" }); + expect((within(otherRow).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(true); +}); + +test("confirming the dialog writes, then kicks rotation after the write resolves", async () => { + let resolveSet: () => void = () => {}; + h.setPerms.mockImplementation(() => new Promise((resolve) => { resolveSet = resolve; })); + + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + fireEvent.click(await screen.findByRole("button", { name: "members.revokeKeyAccess.confirm" })); + + await waitFor(() => + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, PERM_BITS.VIEW_SECRETS), + ); + expect(h.rotate).not.toHaveBeenCalled(); + + resolveSet(); + await waitFor(() => expect(h.rotate).toHaveBeenCalledWith("t1")); +}); + +test("cancelling the dialog writes nothing and rotates nothing", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + fireEvent.click(await screen.findByRole("button", { name: "common.action.cancel" })); + + expect(screen.queryByText("members.revokeKeyAccess.title")).toBeNull(); + expect(h.setPerms).not.toHaveBeenCalled(); + expect(h.rotate).not.toHaveBeenCalled(); +}); + +test("a non-crossing change writes immediately with no dialog and no rotation", async () => { + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.EDIT_KEYS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + + await waitFor(() => + expect(h.setPerms).toHaveBeenCalledWith("t1", "u2", 0, PERM_BITS.EDIT_KEYS), + ); + expect(screen.queryByText("members.revokeKeyAccess.title")).toBeNull(); + expect(h.rotate).not.toHaveBeenCalled(); +}); + +test("a rejected write after confirming does not rotate", async () => { + h.setPerms.mockRejectedValueOnce(new Error("boom")); + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + fireEvent.click(within(row).getByRole("radio", { name: /deny/i })); + fireEvent.click(await screen.findByRole("button", { name: "members.revokeKeyAccess.confirm" })); + + expect(await screen.findByText("boom")).toBeTruthy(); + expect(h.rotate).not.toHaveBeenCalled(); +}); + +test("clearing an allow grant crosses the gate too", async () => { + const rolelessMember = { + ...targetMember, role_ids: [], permission_allow: PERM_BITS.VIEW_SECRETS, permission_deny: 0, + }; + render(); + + const row = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + fireEvent.click(within(row).getByRole("radio", { name: /inherit/i })); + + expect(await screen.findByText("members.revokeKeyAccess.title")).toBeTruthy(); + expect(h.setPerms).not.toHaveBeenCalled(); +}); + +test("a write in flight disables the other rows too", async () => { + let resolveSet: () => void = () => {}; + h.setPerms.mockImplementation(() => new Promise((resolve) => { resolveSet = resolve; })); + + render(); + const row1 = screen.getByRole("radiogroup", { name: "members.permission.VIEW_SECRETS" }); + const row2 = screen.getByRole("radiogroup", { name: "members.permission.EDIT_KEYS" }); + + fireEvent.click(within(row1).getByRole("radio", { name: /deny/i })); + + await waitFor(() => + expect((within(row2).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(true), + ); + + resolveSet(); + await waitFor(() => + expect((within(row2).getByRole("radio", { name: /deny/i }) as HTMLButtonElement).disabled).toBe(false), + ); +}); diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index ef0d8de43..c0d9e9ef2 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -515,12 +515,14 @@ const vaultTabs = selectedVaultIds.length > 1 showDetailPanel && detailMember ? ( setShowDetailPanel(false)} onUpdated={reload} /> diff --git a/src/components/members/panels/MemberDetailPanel.tsx b/src/components/members/panels/MemberDetailPanel.tsx index 7312304af..89529744e 100644 --- a/src/components/members/panels/MemberDetailPanel.tsx +++ b/src/components/members/panels/MemberDetailPanel.tsx @@ -10,7 +10,16 @@ import { RoleModal } from "@/components/settings/sections/RolesSection"; import { ROLE_META, RoleBlurb } from "@/components/members/roleChips"; import { RoleBadges } from "@/components/members/roleBadges"; import { OffboardingDialog } from "@/components/members/OffboardingDialog"; +import { ConfirmModal } from "@/components/shared/ConfirmModal"; import type { DepartMode } from "@/services/teamOffboarding"; +import { + PERM_BITS, PERM_META, effectivePermissions, crossesVaultKeyGate, resolveMemberReadOnlyReason, + type Permission, type MemberReadOnlyReason, +} from "@/services/permissions"; +import { checkAndRotateTeamKey } from "@/services/teamKeyRotation"; +import { + PermissionOverrideRow, overrideStateOf, applyOverrideState, type OverrideState, +} from "./PermissionOverrideRow"; export interface MemberDetailPanelProps { member: TeamMember; @@ -19,16 +28,23 @@ export interface MemberDetailPanelProps { teamRoles: TeamRole[]; canManageMembers: boolean; isTargetOwner: boolean; + viewer?: TeamMember; onClose: () => void; onUpdated: () => void; } +const READONLY_REASON_KEYS: Record = { + noManage: "members.permissions.readOnlyNoManage", + owner: "members.permissions.readOnlyOwner", + self: "members.permissions.readOnlySelf", + higherRole: "members.permissions.readOnlyHigherRole", + notHeld: "members.permissions.readOnlyNotHeld", +}; + export function MemberDetailPanel({ - member, isMe, teamId, teamRoles, canManageMembers, isTargetOwner, onClose, onUpdated, + member, isMe, teamId, teamRoles, canManageMembers, isTargetOwner, viewer, onClose, onUpdated, }: MemberDetailPanelProps) { const { t } = useTranslation(); - const assignMemberRole = useTeamStore((s) => s.assignMemberRole); - const removeMemberRole = useTeamStore((s) => s.removeMemberRole); const push = useHistoryStore((s) => s.push); const [error, setError] = useState(""); @@ -36,6 +52,10 @@ export function MemberDetailPanel({ const [justToggled, setJustToggled] = useState(null); const [offboarding, setOffboarding] = useState(null); const [creatingRole, setCreatingRole] = useState(false); + const [overriding, setOverriding] = useState(false); + // Stores the intent, not the computed masks — commitOverride recomputes them + // from the render current at confirm time, in case member state changed meanwhile. + const [pendingRevoke, setPendingRevoke] = useState<{ permission: Permission; next: OverrideState } | null>(null); const canChangeRoles = canManageMembers && !isMe; const canRemove = canManageMembers && !isTargetOwner && !isMe; @@ -43,51 +63,50 @@ export function MemberDetailPanel({ // offering Leave to an owner would promise something that 403s. const canLeave = isMe && !isTargetOwner; + const runReversible = async (opts: { + pending: string; + success: string; + label: string; + run: () => Promise; + undo: () => Promise; + redo: () => Promise; + }) => { + await runTeamAction({ pending: opts.pending, success: opts.success, run: opts.run }); + push({ + label: opts.label, + undo: async () => { await opts.undo(); onUpdated(); }, + redo: async () => { await opts.redo(); onUpdated(); }, + }); + }; + const handleToggleRole = async (role: TeamRole) => { const hasRole = member.role_ids.includes(role.id); - // Block removing the owner role from an owner if (hasRole && isTargetOwner && role.is_builtin && role.name === "owner") { setError(t("members.error.cannotRemoveOwnerRole")); return; } + const store = useTeamStore.getState(); + const assign = () => store.assignMemberRole(teamId, member.user_id, role.id); + const remove = () => store.removeMemberRole(teamId, member.user_id, role.id); + setToggling(role.id); setError(""); try { - if (hasRole) { - await runTeamAction({ - pending: t("members.toast.removingRoleFrom", { role: role.name, name: member.handle }), - success: t("members.toast.roleRemovedFrom", { role: role.name, name: member.handle }), - run: () => removeMemberRole(teamId, member.user_id, role.id), - }); - push({ - label: t("members.history.removeRole", { name: member.handle }), - undo: async () => { - await useTeamStore.getState().assignMemberRole(teamId, member.user_id, role.id); - onUpdated(); - }, - redo: async () => { - await useTeamStore.getState().removeMemberRole(teamId, member.user_id, role.id); - onUpdated(); - }, - }); - } else { - await runTeamAction({ - pending: t("members.toast.assigningRoleTo", { role: role.name, name: member.handle }), - success: t("members.toast.roleAssignedTo", { role: role.name, name: member.handle }), - run: () => assignMemberRole(teamId, member.user_id, role.id), - }); - push({ - label: t("members.history.assignRole", { name: member.handle }), - undo: async () => { - await useTeamStore.getState().removeMemberRole(teamId, member.user_id, role.id); - onUpdated(); - }, - redo: async () => { - await useTeamStore.getState().assignMemberRole(teamId, member.user_id, role.id); - onUpdated(); - }, - }); - } + await runReversible( + hasRole + ? { + pending: t("members.toast.removingRoleFrom", { role: role.name, name: member.handle }), + success: t("members.toast.roleRemovedFrom", { role: role.name, name: member.handle }), + label: t("members.history.removeRole", { name: member.handle }), + run: remove, undo: assign, redo: remove, + } + : { + pending: t("members.toast.assigningRoleTo", { role: role.name, name: member.handle }), + success: t("members.toast.roleAssignedTo", { role: role.name, name: member.handle }), + label: t("members.history.assignRole", { name: member.handle }), + run: assign, undo: remove, redo: assign, + }, + ); onUpdated(); setJustToggled(role.id); setTimeout(() => setJustToggled(null), 700); @@ -98,6 +117,91 @@ export function MemberDetailPanel({ } }; + const allow = member.permission_allow ?? 0; + const deny = member.permission_deny ?? 0; + const viewerEffective = viewer ? effectivePermissions(viewer, teamRoles) : 0; + + // A server predating overrides omits both masks; a zero mask serializes as 0. + const serverSupportsOverrides = + member.permission_allow !== undefined || member.permission_deny !== undefined; + + // Retired, but shown when set: otherwise no row can clear it as an offending bit. + const editablePermissions = (Object.keys(PERM_META) as Permission[]) + .filter((p) => p !== "CREATE_CUSTOM_ROLES" + || ((allow | deny) & PERM_BITS.CREATE_CUSTOM_ROLES) !== 0); + + const rolesGranting = (permission: Permission) => + teamRoles + .filter((r) => member.role_ids.includes(r.id) && (r.permissions & PERM_BITS[permission]) !== 0) + .map((r) => r.name); + + const offendingBits = allow & ~viewerEffective; + + const readOnlyReasonKind = resolveMemberReadOnlyReason({ + canManageMembers, + isTargetOwner, + isMe, + viewerRoleIds: viewer ? viewer.role_ids : null, + targetRoleIds: member.role_ids, + teamRoles, + offendingBits, + }); + + const readOnlyReason: string | null = readOnlyReasonKind ? t(READONLY_REASON_KEYS[readOnlyReasonKind]) : null; + + // A whole-mask notHeld lock still lets the admin clear the very bit that + // caused it — clearing it produces a mask the server accepts. + const rowDisabled = (permission: Permission) => + overriding || pendingRevoke !== null || (readOnlyReasonKind !== null && + (readOnlyReasonKind !== "notHeld" || (PERM_BITS[permission] & offendingBits) === 0)); + + const write = (masks: { allow: number; deny: number }) => () => + useTeamStore.getState().setMemberPermissions(teamId, member.user_id, masks.allow, masks.deny); + + const commitOverride = async (permission: Permission, next: OverrideState, rotate: boolean) => { + const updated = applyOverrideState(permission, allow, deny, next); + // Undo/redo re-read the masks so a concurrent admin's unrelated bits survive + // the full-replace PUT; only the bit this entry owns moves. A member missing + // from the store can't be safely masked to 0/0 — bail instead of writing empty masks. + const at = (state: OverrideState) => () => { + const m = useTeamStore.getState().membersByTeam[teamId]?.find((x) => x.user_id === member.user_id); + if (!m) throw new Error(t("members.error.failedToUpdatePermissions")); + const masks = applyOverrideState(permission, m.permission_allow ?? 0, m.permission_deny ?? 0, state); + return useTeamStore.getState().setMemberPermissions(teamId, member.user_id, masks.allow, masks.deny); + }; + setError(""); + setOverriding(true); + try { + await runReversible({ + pending: t("members.toast.updatingPermissions", { name: member.handle }), + success: t("members.toast.permissionsUpdated", { name: member.handle }), + label: t("members.history.changePermissions", { name: member.handle }), + run: write(updated), + undo: at(overrideStateOf(permission, allow, deny)), + redo: at(next), + }); + onUpdated(); + if (rotate) void checkAndRotateTeamKey(teamId); + } catch (e) { + setError(e instanceof Error ? e.message : t("members.error.failedToUpdatePermissions")); + } finally { + setOverriding(false); + } + }; + + const handleOverride = async (permission: Permission, next: OverrideState) => { + if (next === "allow" && (viewerEffective & PERM_BITS[permission]) === 0) { + setError(t("members.permissions.readOnlyNotHeld")); + return; + } + const updated = applyOverrideState(permission, allow, deny, next); + if (crossesVaultKeyGate(member, teamRoles, updated)) { + setPendingRevoke({ permission, next }); + return; + } + await commitOverride(permission, next, false); + }; + const joinedDate = new Date(member.joined_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric", }); @@ -180,6 +284,31 @@ export function MemberDetailPanel({ )} + {/* Permissions */} + {serverSupportsOverrides && ( + + {readOnlyReason && ( +

{readOnlyReason}

+ )} +
+ {editablePermissions.map((permission) => { + const granting = rolesGranting(permission); + return ( + 0} + disabled={rowDisabled(permission)} + onChange={(next) => void handleOverride(permission, next)} + /> + ); + })} +
+
+ )} + {/* Info */}
@@ -229,6 +358,21 @@ export function MemberDetailPanel({ onDone={() => { onClose(); onUpdated(); }} /> )} + + {pendingRevoke && ( + setPendingRevoke(null)} + onConfirm={() => { + const { permission, next } = pendingRevoke; + setPendingRevoke(null); + void commitOverride(permission, next, true); + }} + /> + )} ); } diff --git a/src/components/members/panels/PermissionOverrideRow.test.tsx b/src/components/members/panels/PermissionOverrideRow.test.tsx new file mode 100644 index 000000000..535afc887 --- /dev/null +++ b/src/components/members/panels/PermissionOverrideRow.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: { roles?: string }) => (o?.roles ? `${k} ${o.roles}` : k), + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +import { PERM_BITS } from "@/services/permissions"; +import { + PermissionOverrideRow, overrideStateOf, applyOverrideState, +} from "./PermissionOverrideRow"; + +afterEach(cleanup); + +describe("overrideStateOf", () => { + it("reads inherit when the bit is in neither mask", () => { + expect(overrideStateOf("CONNECT", 0, 0)).toBe("inherit"); + }); + it("reads allow when the bit is in the allow mask", () => { + expect(overrideStateOf("CONNECT", PERM_BITS.CONNECT, 0)).toBe("allow"); + }); + it("reads deny when the bit is in the deny mask", () => { + expect(overrideStateOf("CONNECT", 0, PERM_BITS.CONNECT)).toBe("deny"); + }); + it("prefers deny when the bit is in both", () => { + expect(overrideStateOf("CONNECT", PERM_BITS.CONNECT, PERM_BITS.CONNECT)).toBe("deny"); + }); +}); + +describe("applyOverrideState", () => { + it("moves a bit from allow to deny without leaving it in both", () => { + expect(applyOverrideState("CONNECT", PERM_BITS.CONNECT, 0, "deny")).toEqual({ + allow: 0, deny: PERM_BITS.CONNECT, + }); + }); + it("clears the bit from both masks on inherit", () => { + expect(applyOverrideState("CONNECT", 0, PERM_BITS.CONNECT, "inherit")).toEqual({ + allow: 0, deny: 0, + }); + }); + it("leaves other bits untouched", () => { + const { allow, deny } = applyOverrideState("CONNECT", PERM_BITS.EDIT_KEYS, PERM_BITS.VIEW_SECRETS, "allow"); + expect(allow).toBe(PERM_BITS.EDIT_KEYS | PERM_BITS.CONNECT); + expect(deny).toBe(PERM_BITS.VIEW_SECRETS); + }); +}); + +describe("PermissionOverrideRow", () => { + const base = { + permission: "CONNECT" as const, + state: "inherit" as const, + inheritedFrom: ["editor"], + inheritedGrants: true, + disabled: false, + }; + + it("names the roles the bit is inherited from", () => { + render(); + expect(screen.getByText(/editor/)).toBeTruthy(); + }); + + it("emits the chosen state", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("radio", { name: /deny/i })); + expect(onChange).toHaveBeenCalledWith("deny"); + }); + + it("does not emit while disabled", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("radio", { name: /deny/i })); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/members/panels/PermissionOverrideRow.tsx b/src/components/members/panels/PermissionOverrideRow.tsx new file mode 100644 index 000000000..daa38f617 --- /dev/null +++ b/src/components/members/panels/PermissionOverrideRow.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { PERM_BITS, type Permission } from "@/services/permissions"; +import { permissionLabel } from "@/components/members/roleChips"; + +export type OverrideState = "deny" | "inherit" | "allow"; + +const STATES: { value: OverrideState; icon: string; color: string }[] = [ + { value: "deny", icon: "lucide:x", color: "var(--t-status-error)" }, + { value: "inherit", icon: "lucide:minus", color: "var(--t-text-dim)" }, + { value: "allow", icon: "lucide:check", color: "#34d399" }, +]; + +export function overrideStateOf(permission: Permission, allow: number, deny: number): OverrideState { + const bit = PERM_BITS[permission]; + if ((deny & bit) !== 0) return "deny"; + if ((allow & bit) !== 0) return "allow"; + return "inherit"; +} + +export function applyOverrideState( + permission: Permission, + allow: number, + deny: number, + next: OverrideState, +): { allow: number; deny: number } { + const bit = PERM_BITS[permission]; + const clearedAllow = allow & ~bit; + const clearedDeny = deny & ~bit; + if (next === "allow") return { allow: clearedAllow | bit, deny: clearedDeny }; + if (next === "deny") return { allow: clearedAllow, deny: clearedDeny | bit }; + return { allow: clearedAllow, deny: clearedDeny }; +} + +export interface PermissionOverrideRowProps { + permission: Permission; + state: OverrideState; + inheritedFrom: string[]; + inheritedGrants: boolean; + disabled: boolean; + onChange: (next: OverrideState) => void; +} + +export function PermissionOverrideRow({ + permission, state, inheritedFrom, inheritedGrants, disabled, onChange, +}: PermissionOverrideRowProps) { + const { t } = useTranslation(); + const label = permissionLabel(t, permission); + + const source = inheritedFrom.length > 0 + ? t("members.permissions.inheritedFrom", { roles: inheritedFrom.join(", ") }) + : t("members.permissions.notGranted"); + + return ( +
+
+

{label}

+

+ {source} + {state === "inherit" && ( + <> · {inheritedGrants ? t("members.permissions.effectiveAllowed") : t("members.permissions.effectiveDenied")} + )} +

+
+
+ {STATES.map((s) => { + const active = s.value === state; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/members/roleBadges.test.tsx b/src/components/members/roleBadges.test.tsx new file mode 100644 index 000000000..bb181aa18 --- /dev/null +++ b/src/components/members/roleBadges.test.tsx @@ -0,0 +1,74 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +vi.mock("@iconify/react", () => ({ Icon: ({ icon }: { icon: string }) => })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +import { PERM_BITS } from "@/services/permissions"; +import type { TeamMember, TeamRole } from "@/services/teamService"; +import { RoleBadges } from "./roleBadges"; + +afterEach(cleanup); + +const member = (allow: number, deny: number, role_ids: string[] = []): TeamMember => ({ + team_id: "t1", user_id: "u1", handle: "alice", public_key: "k", + invited_by_display_name: null, joined_at: "", role_ids, + permission_allow: allow, permission_deny: deny, +}); + +const role: TeamRole = { + id: "r1", team_id: "t1", name: "editor", permissions: 0, + is_builtin: true, position: 0, created_at: "", +}; + +test("no-role member with an allow override shows the marker", () => { + render(); + expect(screen.getByTestId("override-marker")).toBeTruthy(); +}); + +test("no-role member with a deny override shows the marker", () => { + render(); + expect(screen.getByTestId("override-marker")).toBeTruthy(); +}); + +test("no-role member with both masks empty shows no marker", () => { + render(); + expect(screen.queryByTestId("override-marker")).toBeNull(); +}); + +test("no-role member with canManage and onAddRole still shows the marker", () => { + render( + {}} />, + ); + expect(screen.getByTestId("override-marker")).toBeTruthy(); +}); + +test("no-role member with canManage and onAddRole and no overrides shows no marker", () => { + render( {}} />); + expect(screen.queryByTestId("override-marker")).toBeNull(); +}); + +test("the noRole wrapper is inline, not a block, so surrounding text does not break", () => { + render(); + expect(screen.getByTestId("override-marker").parentElement?.tagName).toBe("SPAN"); +}); + +test("the add-role wrapper is inline, not a block, so surrounding text does not break", () => { + render( + {}} />, + ); + expect(screen.getByTestId("override-marker").parentElement?.tagName).toBe("SPAN"); +}); + +test("member with a resolvable role and an override shows the marker", () => { + render(); + expect(screen.getByTestId("override-marker")).toBeTruthy(); +}); + +test("member with a resolvable role and no overrides shows no marker", () => { + render(); + expect(screen.queryByTestId("override-marker")).toBeNull(); +}); diff --git a/src/components/members/roleBadges.tsx b/src/components/members/roleBadges.tsx index 9835c6dac..fd7a9a32f 100644 --- a/src/components/members/roleBadges.tsx +++ b/src/components/members/roleBadges.tsx @@ -5,6 +5,8 @@ import type { TeamMember, TeamRole } from "@/stores/teamStore"; import { avatarColor } from "@/components/shared/AvatarStack"; import { ROLE_META, RolePermissionTooltip } from "@/components/members/roleChips"; +const INLINE_WRAPPER_CLASS = "inline-flex items-center gap-1"; + export function RoleChip({ role }: { role: TeamRole }) { const [showTip, setShowTip] = useState(false); const meta = ROLE_META[role.name]; @@ -43,25 +45,45 @@ export function RoleBadges({ .map((rid) => roles.find((r) => r.id === rid)) .filter(Boolean) as TeamRole[]; memberRoles.sort((a, b) => a.position - b.position); + const hasOverrides = ((member.permission_allow ?? 0) | (member.permission_deny ?? 0)) !== 0; + const marker = hasOverrides ? ( + + + + ) : null; if (memberRoles.length === 0) { if (canManage && onAddRole) { return ( - + + + {marker} + ); } - return {t("members.noRole")}; + return ( + + {t("members.noRole")} + {marker} + + ); } return (
{memberRoles.map((r) => )} + {marker}
); } diff --git a/src/components/members/roleChips.tsx b/src/components/members/roleChips.tsx index 9ab7a49cb..b251fadb5 100644 --- a/src/components/members/roleChips.tsx +++ b/src/components/members/roleChips.tsx @@ -1,10 +1,11 @@ import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import type { TeamRole } from "@/services/teamService"; // Both from the service, not the hook: @/hooks/usePermission pulls teamService // and i18n in at runtime, which every consumer of this leaf module would then // have to mock. -import { PERM_BITS, PERM_META } from "@/services/permissions"; +import { PERM_BITS, PERM_META, type Permission } from "@/services/permissions"; export const ROLE_META: Record = { owner: { label: "Owner", color: "#a78bfa", bg: "rgba(167,139,250,0.12)" }, @@ -24,6 +25,10 @@ export function roleChipColors(name: string, override?: string | null, fallback return { meta, color, bg: meta?.bg ?? `${color}1a` }; } +export function permissionLabel(t: TFunction, permission: Permission): string { + return t(`members.permission.${permission}`, { defaultValue: PERM_META[permission]?.label ?? permission }); +} + /** * The precise half of the role explanation: exactly which permissions a role * grants, derived from its bits. The plain-language counterpart is @@ -33,10 +38,7 @@ export function RolePermissionTooltip({ role, color }: { role: TeamRole; color: const { t } = useTranslation(); const permLabels = Object.entries(PERM_BITS) .filter(([, bit]) => (role.permissions & bit) !== 0) - .map(([p]) => { - const key = p as keyof typeof PERM_META; - return t(`members.permission.${key}`, { defaultValue: PERM_META[key]?.label ?? p }); - }); + .map(([p]) => permissionLabel(t, p as Permission)); if (permLabels.length === 0) return null; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 53c4f421c..3a93b056b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -120,6 +120,8 @@ "failedToRemoveMember": "Failed to remove member: {{status}}", "failedToAssignRole": "Failed to assign role: {{status}}", "failedToRemoveRole": "Failed to remove role: {{status}}", + "insufficientPermissionSetMemberPermissions": "You do not have permission to change this member's permissions", + "failedToSetMemberPermissions": "Failed to set member permissions: {{status}}", "failedToCreateRole": "Failed to create role: {{status}}", "failedToUpdateRole": "Failed to update role: {{status}}", "failedToDeleteRole": "Failed to delete role: {{status}}", diff --git a/src/i18n/locales/en/logs.json b/src/i18n/locales/en/logs.json index 17f7ce71b..b6c1510de 100644 --- a/src/i18n/locales/en/logs.json +++ b/src/i18n/locales/en/logs.json @@ -23,6 +23,7 @@ "memberJoined": "Member joined", "memberRemoved": "Member removed", "memberRoleChanged": "Role changed", + "memberPermissionsChanged": "Permissions changed", "connectionCreated": "Host created", "connectionUpdated": "Host updated", "connectionDeleted": "Host deleted", @@ -79,6 +80,7 @@ "memberJoined": "joined the team as {{role}}", "memberRemoved": "removed {{name}}", "memberRoleChanged": "changed role for {{name}}", + "memberPermissionsChanged": "changed permissions for {{name}}", "vaultCreated": "created vault \"{{name}}\"", "vaultDeleted": "deleted vault \"{{name}}\"", "vaultRenamed": "renamed vault to \"{{name}}\"", diff --git a/src/i18n/locales/en/members.json b/src/i18n/locales/en/members.json index c8082f252..24ddb51bf 100644 --- a/src/i18n/locales/en/members.json +++ b/src/i18n/locales/en/members.json @@ -51,6 +51,25 @@ "JOIN_TERMINAL_SESSION": "Join sessions", "VIEW_TERMINAL_SESSIONS": "View sessions" }, + "permissions": { + "title": "Permissions", + "inheritedFrom": "From {{roles}}", + "notGranted": "No role grants this", + "effectiveAllowed": "allowed", + "effectiveDenied": "denied", + "state": { "deny": "Deny", "inherit": "Inherit", "allow": "Allow" }, + "readOnlyNoManage": "You need the Manage members permission", + "readOnlyOwner": "An owner's permissions cannot be overridden", + "readOnlySelf": "You cannot change your own permissions", + "readOnlyHigherRole": "This member holds a role at or above yours", + "readOnlyNotHeld": "You cannot grant a permission you do not hold", + "overrideMarker": "Has permission overrides" + }, + "revokeKeyAccess": { + "title": "Revoke {{name}}'s access to the vault key?", + "body": "{{name}} will no longer be able to connect or view secrets in this vault. They keep a copy of the current key and can still read secrets already synced to their device, so the vault key will be rotated for the remaining members.", + "confirm": "Revoke and rotate key" + }, "convert": { "title": "Share \"{{vault}}\" with other people?", "body": "This turns your private vault into a team vault. Everything already in it moves across, so the people you add see it too.", @@ -126,11 +145,14 @@ "grantingKey": "Granting vault access to {{name}}…", "keyGranted": "{{name}} can now open the vault", "leavingTeam": "Leaving team...", - "leftTeam": "You left the team" + "leftTeam": "You left the team", + "updatingPermissions": "Updating {{name}}'s permissions…", + "permissionsUpdated": "Updated {{name}}'s permissions" }, "error": { "cannotRemoveOwnerRole": "Cannot remove the owner role from the team owner", "failedToUpdateRole": "Failed to update role", + "failedToUpdatePermissions": "Failed to update permissions", "failedToRemoveMember": "Failed to remove member", "failedToAddMember": "Failed to add member", "inviteFailed": "Could not invite {{name}} — {{reason}}", @@ -145,6 +167,7 @@ "history": { "removeRole": "Remove role: {{name}}", "assignRole": "Assign role: {{name}}", + "changePermissions": "Change {{name}}'s permissions", "remove": "Remove: {{name}}", "assignRoleBulk": "Assign role ×{{count}}", "removeRoleBulk": "Remove role ×{{count}}", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index eae8096d3..f1cfba2c5 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -120,6 +120,8 @@ "failedToRemoveMember": "Échec de la suppression du membre : {{status}}", "failedToAssignRole": "Échec de l'attribution du rôle : {{status}}", "failedToRemoveRole": "Échec du retrait du rôle : {{status}}", + "insufficientPermissionSetMemberPermissions": "Vous n'avez pas la permission de modifier les permissions de ce membre", + "failedToSetMemberPermissions": "Échec de la définition des permissions du membre : {{status}}", "failedToCreateRole": "Échec de la création du rôle : {{status}}", "failedToUpdateRole": "Échec de la mise à jour du rôle : {{status}}", "failedToDeleteRole": "Échec de la suppression du rôle : {{status}}", diff --git a/src/i18n/locales/fr/logs.json b/src/i18n/locales/fr/logs.json index dec13eb31..a4c16f1dc 100644 --- a/src/i18n/locales/fr/logs.json +++ b/src/i18n/locales/fr/logs.json @@ -23,6 +23,7 @@ "memberJoined": "Membre rejoint", "memberRemoved": "Membre retiré", "memberRoleChanged": "Rôle modifié", + "memberPermissionsChanged": "Permissions modifiées", "connectionCreated": "Hôte créé", "connectionUpdated": "Hôte modifié", "connectionDeleted": "Hôte supprimé", @@ -79,6 +80,7 @@ "memberJoined": "a rejoint l'équipe en tant que {{role}}", "memberRemoved": "a retiré {{name}}", "memberRoleChanged": "a changé le rôle de {{name}}", + "memberPermissionsChanged": "a changé les permissions de {{name}}", "vaultCreated": "a créé le coffre « {{name}} »", "vaultDeleted": "a supprimé le coffre « {{name}} »", "vaultRenamed": "a renommé le coffre en « {{name}} »", diff --git a/src/i18n/locales/fr/members.json b/src/i18n/locales/fr/members.json index cf1f3048f..4180e9f2a 100644 --- a/src/i18n/locales/fr/members.json +++ b/src/i18n/locales/fr/members.json @@ -51,6 +51,25 @@ "JOIN_TERMINAL_SESSION": "Rejoindre des sessions", "VIEW_TERMINAL_SESSIONS": "Voir les sessions" }, + "permissions": { + "title": "Permissions", + "inheritedFrom": "Via {{roles}}", + "notGranted": "Aucun rôle ne l'accorde", + "effectiveAllowed": "autorisé", + "effectiveDenied": "refusé", + "state": { "deny": "Refuser", "inherit": "Hériter", "allow": "Autoriser" }, + "readOnlyNoManage": "Vous avez besoin de la permission Gérer les membres", + "readOnlyOwner": "Les permissions d'un propriétaire ne peuvent pas être modifiées", + "readOnlySelf": "Vous ne pouvez pas modifier vos propres permissions", + "readOnlyHigherRole": "Ce membre a un rôle égal ou supérieur au vôtre", + "readOnlyNotHeld": "Vous ne pouvez pas accorder une permission que vous n'avez pas", + "overrideMarker": "Permissions personnalisées" + }, + "revokeKeyAccess": { + "title": "Retirer à {{name}} l'accès à la clé du coffre ?", + "body": "{{name}} ne pourra plus se connecter ni voir les secrets de ce coffre. Cette personne conserve une copie de la clé actuelle et peut encore lire les secrets déjà synchronisés sur son appareil ; la clé du coffre sera donc changée pour les membres restants.", + "confirm": "Retirer et changer la clé" + }, "convert": { "title": "Partager « {{vault}} » avec d'autres personnes ?", "body": "Ceci transforme votre coffre-fort privé en coffre-fort d'équipe. Tout ce qu'il contient déjà y est transféré, et devient donc visible par les personnes que vous ajoutez.", @@ -126,11 +145,14 @@ "grantingKey": "Octroi de l'accès au coffre-fort à {{name}}…", "keyGranted": "{{name}} peut maintenant ouvrir le coffre-fort", "leavingTeam": "Départ de l'équipe…", - "leftTeam": "Vous avez quitté l'équipe" + "leftTeam": "Vous avez quitté l'équipe", + "updatingPermissions": "Mise à jour des permissions de {{name}}…", + "permissionsUpdated": "Permissions de {{name}} mises à jour" }, "error": { "cannotRemoveOwnerRole": "Impossible de retirer le rôle de propriétaire au propriétaire de l'équipe", "failedToUpdateRole": "Échec de la mise à jour du rôle", + "failedToUpdatePermissions": "Échec de la mise à jour des permissions", "failedToRemoveMember": "Échec de la suppression du membre", "failedToAddMember": "Échec de l'ajout du membre", "inviteFailed": "Impossible d'inviter {{name}} — {{reason}}", @@ -145,6 +167,7 @@ "history": { "removeRole": "Retirer le rôle : {{name}}", "assignRole": "Attribuer un rôle : {{name}}", + "changePermissions": "Modifier les permissions de {{name}}", "remove": "Retirer : {{name}}", "assignRoleBulk": "Attribuer un rôle ×{{count}}", "removeRoleBulk": "Retirer un rôle ×{{count}}", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index bd4cc39c6..079e2c0e3 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -120,6 +120,8 @@ "failedToRemoveMember": "Не удалось удалить участника: {{status}}", "failedToAssignRole": "Не удалось назначить роль: {{status}}", "failedToRemoveRole": "Не удалось удалить роль: {{status}}", + "insufficientPermissionSetMemberPermissions": "У вас нет прав на изменение разрешений этого участника", + "failedToSetMemberPermissions": "Не удалось задать разрешения участника: {{status}}", "failedToCreateRole": "Не удалось создать роль: {{status}}", "failedToUpdateRole": "Не удалось обновить роль: {{status}}", "failedToDeleteRole": "Не удалось удалить роль: {{status}}", diff --git a/src/i18n/locales/ru/logs.json b/src/i18n/locales/ru/logs.json index 5ab1a34c4..ba899a787 100644 --- a/src/i18n/locales/ru/logs.json +++ b/src/i18n/locales/ru/logs.json @@ -23,6 +23,7 @@ "memberJoined": "Участник присоединился", "memberRemoved": "Участник удалён", "memberRoleChanged": "Роль изменена", + "memberPermissionsChanged": "Разрешения изменены", "connectionCreated": "Хост создан", "connectionUpdated": "Хост обновлён", "connectionDeleted": "Хост удалён", @@ -79,6 +80,7 @@ "memberJoined": "присоединился(лась) к команде как {{role}}", "memberRemoved": "удалил(а) {{name}}", "memberRoleChanged": "изменил(а) роль для {{name}}", + "memberPermissionsChanged": "изменил(а) разрешения для {{name}}", "vaultCreated": "создал(а) хранилище \"{{name}}\"", "vaultDeleted": "удалил(а) хранилище \"{{name}}\"", "vaultRenamed": "переименовал(а) хранилище в \"{{name}}\"", diff --git a/src/i18n/locales/ru/members.json b/src/i18n/locales/ru/members.json index e4632c4e3..8d6e01e3f 100644 --- a/src/i18n/locales/ru/members.json +++ b/src/i18n/locales/ru/members.json @@ -53,6 +53,25 @@ "JOIN_TERMINAL_SESSION": "Присоединение к сессиям", "VIEW_TERMINAL_SESSIONS": "Просмотр сессий" }, + "permissions": { + "title": "Разрешения", + "inheritedFrom": "От {{roles}}", + "notGranted": "Ни одна роль не даёт этого", + "effectiveAllowed": "разрешено", + "effectiveDenied": "запрещено", + "state": { "deny": "Запретить", "inherit": "Наследовать", "allow": "Разрешить" }, + "readOnlyNoManage": "Требуется разрешение «Управление участниками»", + "readOnlyOwner": "Разрешения владельца изменить нельзя", + "readOnlySelf": "Нельзя изменить собственные разрешения", + "readOnlyHigherRole": "У этого участника роль не ниже вашей", + "readOnlyNotHeld": "Нельзя выдать разрешение, которого у вас нет", + "overrideMarker": "Есть переопределения разрешений" + }, + "revokeKeyAccess": { + "title": "Отозвать у {{name}} доступ к ключу хранилища?", + "body": "{{name}} больше не сможет подключаться и просматривать секреты этого хранилища. У участника остаётся копия текущего ключа, и уже синхронизированные на его устройство секреты по-прежнему доступны для чтения, поэтому для остальных участников будет выполнена ротация ключа хранилища.", + "confirm": "Отозвать и выполнить ротацию ключа" + }, "convert": { "title": "Предоставить общий доступ к \"{{vault}}\" другим людям?", "body": "Это превратит ваше личное хранилище в командное. Всё, что уже в нём есть, переносится туда, поэтому добавленные вами люди тоже это увидят.", @@ -130,11 +149,14 @@ "grantingKey": "Предоставление доступа к хранилищу для {{name}}…", "keyGranted": "{{name}} теперь может открыть хранилище", "leavingTeam": "Выход из команды…", - "leftTeam": "Вы вышли из команды" + "leftTeam": "Вы вышли из команды", + "updatingPermissions": "Обновление разрешений {{name}}…", + "permissionsUpdated": "Разрешения {{name}} обновлены" }, "error": { "cannotRemoveOwnerRole": "Нельзя удалить роль владельца у владельца команды", "failedToUpdateRole": "Не удалось обновить роль", + "failedToUpdatePermissions": "Не удалось обновить разрешения", "failedToRemoveMember": "Не удалось удалить участника", "failedToAddMember": "Не удалось добавить участника", "inviteFailed": "Не удалось пригласить {{name}} — {{reason}}", @@ -149,6 +171,7 @@ "history": { "removeRole": "Удалить роль: {{name}}", "assignRole": "Назначить роль: {{name}}", + "changePermissions": "Изменить разрешения {{name}}", "remove": "Удалить: {{name}}", "assignRoleBulk": "Назначить роль ×{{count}}", "removeRoleBulk": "Удалить роль ×{{count}}", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index aa0a201fd..275b5a92e 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -120,6 +120,8 @@ "failedToRemoveMember": "移除成员失败:{{status}}", "failedToAssignRole": "分配角色失败:{{status}}", "failedToRemoveRole": "移除角色失败:{{status}}", + "insufficientPermissionSetMemberPermissions": "您没有权限修改该成员的权限", + "failedToSetMemberPermissions": "设置成员权限失败:{{status}}", "failedToCreateRole": "创建角色失败:{{status}}", "failedToUpdateRole": "更新角色失败:{{status}}", "failedToDeleteRole": "删除角色失败:{{status}}", diff --git a/src/i18n/locales/zh/logs.json b/src/i18n/locales/zh/logs.json index 572d636fd..9ea8b138d 100644 --- a/src/i18n/locales/zh/logs.json +++ b/src/i18n/locales/zh/logs.json @@ -23,6 +23,7 @@ "memberJoined": "成员已加入", "memberRemoved": "已移除成员", "memberRoleChanged": "角色已更改", + "memberPermissionsChanged": "权限已更改", "connectionCreated": "已创建主机", "connectionUpdated": "已更新主机", "connectionDeleted": "已删除主机", @@ -79,6 +80,7 @@ "memberJoined": "以 {{role}} 身份加入了团队", "memberRemoved": "移除了 {{name}}", "memberRoleChanged": "更改了 {{name}} 的角色", + "memberPermissionsChanged": "更改了 {{name}} 的权限", "vaultCreated": "创建了保险库 \"{{name}}\"", "vaultDeleted": "删除了保险库 \"{{name}}\"", "vaultRenamed": "将保险库重命名为 \"{{name}}\"", diff --git a/src/i18n/locales/zh/members.json b/src/i18n/locales/zh/members.json index f5d694521..ce6585685 100644 --- a/src/i18n/locales/zh/members.json +++ b/src/i18n/locales/zh/members.json @@ -51,6 +51,25 @@ "JOIN_TERMINAL_SESSION": "加入会话", "VIEW_TERMINAL_SESSIONS": "查看会话" }, + "permissions": { + "title": "权限", + "inheritedFrom": "来自 {{roles}}", + "notGranted": "没有角色授予此权限", + "effectiveAllowed": "已允许", + "effectiveDenied": "已拒绝", + "state": { "deny": "拒绝", "inherit": "继承", "allow": "允许" }, + "readOnlyNoManage": "需要「管理成员」权限", + "readOnlyOwner": "无法覆盖所有者的权限", + "readOnlySelf": "无法修改自己的权限", + "readOnlyHigherRole": "该成员的角色不低于你", + "readOnlyNotHeld": "无法授予你自己没有的权限", + "overrideMarker": "存在权限覆盖" + }, + "revokeKeyAccess": { + "title": "撤销 {{name}} 对保险库密钥的访问权限?", + "body": "{{name}} 将无法再连接或查看此保险库中的机密。该成员仍持有当前密钥的副本,已同步到其设备上的机密依然可读,因此将为其余成员轮换保险库密钥。", + "confirm": "撤销并轮换密钥" + }, "convert": { "title": "要与他人共享 \"{{vault}}\" 吗?", "body": "这会将您的私有保险库转变为团队保险库。其中已有的所有内容都会一并转移,因此您添加的人也能看到。", @@ -126,11 +145,14 @@ "grantingKey": "正在为 {{name}} 授予保险库访问权…", "keyGranted": "{{name}} 现在可以打开保险库", "leavingTeam": "正在退出团队…", - "leftTeam": "你已退出团队" + "leftTeam": "你已退出团队", + "updatingPermissions": "正在更新 {{name}} 的权限…", + "permissionsUpdated": "已更新 {{name}} 的权限" }, "error": { "cannotRemoveOwnerRole": "无法从团队所有者移除所有者角色", "failedToUpdateRole": "更新角色失败", + "failedToUpdatePermissions": "更新权限失败", "failedToRemoveMember": "移除成员失败", "failedToAddMember": "添加成员失败", "inviteFailed": "无法邀请 {{name}} — {{reason}}", @@ -145,6 +167,7 @@ "history": { "removeRole": "移除角色:{{name}}", "assignRole": "分配角色:{{name}}", + "changePermissions": "修改 {{name}} 的权限", "remove": "移除:{{name}}", "assignRoleBulk": "分配角色 ×{{count}}", "removeRoleBulk": "移除角色 ×{{count}}", diff --git a/src/services/permissions.test.ts b/src/services/permissions.test.ts index fd0578844..3c3932d28 100644 --- a/src/services/permissions.test.ts +++ b/src/services/permissions.test.ts @@ -1,5 +1,8 @@ -import { test, expect } from "vitest"; -import { resolveCan, PERM_BITS, type PermissionSnapshot } from "./permissions.ts"; +import { test, expect, describe, it } from "vitest"; +import { + resolveCan, PERM_BITS, effectivePermissions, crossesVaultKeyGate, resolveMemberReadOnlyReason, + type PermissionSnapshot, +} from "./permissions.ts"; import type { Team, TeamMember, TeamRole } from "@/services/teamService"; import type { Vault } from "@/stores/vaultStore"; @@ -76,3 +79,163 @@ test("fallback denies when team unknown or roles empty", () => { resolveCan(snap({ teams: [team("t1", ["r1"])], rolesByTeam: { t1: [] } }), "VIEW_SECRETS", "t1"), ).toBe(false); }); + +describe("effectivePermissions with member overrides", () => { + const roles: TeamRole[] = [ + { id: "r1", team_id: "t1", name: "editor", permissions: PERM_BITS.CONNECT | PERM_BITS.VIEW_SECRETS, is_builtin: true, position: 2, created_at: "" }, + ]; + + it("returns the role union when no override is present", () => { + expect(effectivePermissions({ role_ids: ["r1"] }, roles)).toBe( + PERM_BITS.CONNECT | PERM_BITS.VIEW_SECRETS, + ); + }); + + it("adds allowed bits the roles do not grant", () => { + expect( + effectivePermissions({ role_ids: ["r1"], permission_allow: PERM_BITS.EDIT_KEYS }, roles), + ).toBe(PERM_BITS.CONNECT | PERM_BITS.VIEW_SECRETS | PERM_BITS.EDIT_KEYS); + }); + + it("removes denied bits the roles do grant", () => { + expect( + effectivePermissions({ role_ids: ["r1"], permission_deny: PERM_BITS.VIEW_SECRETS }, roles), + ).toBe(PERM_BITS.CONNECT); + }); + + it("lets deny win when a role-granted bit is in both allow and deny masks", () => { + expect( + effectivePermissions( + { role_ids: ["r1"], permission_allow: PERM_BITS.VIEW_SECRETS, permission_deny: PERM_BITS.VIEW_SECRETS }, + roles, + ), + ).toBe(PERM_BITS.CONNECT); + }); + + it("grants an allowed bit to a member holding no roles", () => { + expect( + effectivePermissions({ role_ids: [], permission_allow: PERM_BITS.CONNECT }, roles), + ).toBe(PERM_BITS.CONNECT); + }); +}); + +describe("crossesVaultKeyGate", () => { + it("role grants VIEW_SECRETS only; deny VIEW_SECRETS crosses the gate", () => { + const roles: TeamRole[] = [role("r1", PERM_BITS.VIEW_SECRETS)]; + const m = { ...member("u1", ["r1"]), permission_allow: 0, permission_deny: 0 }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: PERM_BITS.VIEW_SECRETS }), + ).toBe(true); + }); + + it("already denying VIEW_SECRETS; submitting the identical deny again does not cross", () => { + const roles: TeamRole[] = [role("r1", PERM_BITS.VIEW_SECRETS)]; + const m = { ...member("u1", ["r1"]), permission_allow: 0, permission_deny: PERM_BITS.VIEW_SECRETS }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: PERM_BITS.VIEW_SECRETS }), + ).toBe(false); + }); + + it("role grants VIEW_SECRETS and CONNECT; deny VIEW_SECRETS only does not cross (CONNECT still gates)", () => { + const roles: TeamRole[] = [role("r1", PERM_BITS.VIEW_SECRETS | PERM_BITS.CONNECT)]; + const m = { ...member("u1", ["r1"]), permission_allow: 0, permission_deny: 0 }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: PERM_BITS.VIEW_SECRETS }), + ).toBe(false); + }); + + it("role grants VIEW_SECRETS and COPY_SECRETS; deny COPY_SECRETS only does not cross", () => { + const roles: TeamRole[] = [role("r1", PERM_BITS.VIEW_SECRETS | PERM_BITS.COPY_SECRETS)]; + const m = { ...member("u1", ["r1"]), permission_allow: 0, permission_deny: 0 }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: PERM_BITS.COPY_SECRETS }), + ).toBe(false); + }); + + it("roleless member with allow VIEW_SECRETS; clearing to inherit crosses", () => { + const roles: TeamRole[] = []; + const m = { ...member("u1", []), permission_allow: PERM_BITS.VIEW_SECRETS, permission_deny: 0 }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: 0 }), + ).toBe(true); + }); + + it("role grants VIEW_SECRETS; deny EDIT_KEYS does not cross", () => { + const roles: TeamRole[] = [role("r1", PERM_BITS.VIEW_SECRETS)]; + const m = { ...member("u1", ["r1"]), permission_allow: 0, permission_deny: 0 }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: PERM_BITS.EDIT_KEYS }), + ).toBe(false); + }); + + it("role grants VIEW_SECRETS and an unrelated bit; deny VIEW_SECRETS still crosses", () => { + const roles: TeamRole[] = [role("r1", PERM_BITS.VIEW_SECRETS | PERM_BITS.EDIT_KEYS)]; + const m = { ...member("u1", ["r1"]), permission_allow: 0, permission_deny: 0 }; + expect( + crossesVaultKeyGate(m, roles, { allow: 0, deny: PERM_BITS.VIEW_SECRETS }), + ).toBe(true); + }); +}); + +describe("resolveMemberReadOnlyReason", () => { + const admin = role("r-admin", 0, { position: 0 }); + const target = role("r-target", 0, { position: 1 }); + + function reason(over: Partial[0]> = {}) { + return resolveMemberReadOnlyReason({ + canManageMembers: true, + isTargetOwner: false, + isMe: false, + viewerRoleIds: ["r-admin"], + targetRoleIds: ["r-target"], + teamRoles: [admin, target], + offendingBits: 0, + ...over, + }); + } + + it("no manage permission wins first", () => { + expect(reason({ canManageMembers: false })).toBe("noManage"); + }); + + it("target is owner", () => { + expect(reason({ isTargetOwner: true })).toBe("owner"); + }); + + it("editing yourself", () => { + expect(reason({ isMe: true })).toBe("self"); + }); + + it("viewer strictly above target: no reason", () => { + expect(reason()).toBeNull(); + }); + + it("viewer at or below target's position: higherRole", () => { + expect(reason({ viewerRoleIds: ["r-target"], targetRoleIds: ["r-admin"] })).toBe("higherRole"); + }); + + it("absent viewer fails closed: higherRole", () => { + expect(reason({ viewerRoleIds: null })).toBe("higherRole"); + }); + + it("roleless viewer fails closed: higherRole", () => { + expect(reason({ viewerRoleIds: [] })).toBe("higherRole"); + }); + + it("roleless target passes hierarchy", () => { + expect(reason({ targetRoleIds: [] })).toBeNull(); + }); + + it("hierarchy passes but offending bits remain: notHeld", () => { + expect(reason({ offendingBits: PERM_BITS.VIEW_SECRETS })).toBe("notHeld"); + }); +}); + +test("resolveCan uses team-level deny in the team fallback (before membersByTeam loads)", () => { + const s = snap({ + teams: [{ id: "t1", name: "t1", owner_id: "o", owner_tier: "team", created_at: "", role_ids: ["r1"], permission_deny: PERM_BITS.VIEW_SECRETS }], + rolesByTeam: { t1: [role("r1", PERM_BITS.VIEW_SECRETS | PERM_BITS.CONNECT)] }, + }); + expect(resolveCan(s, "VIEW_SECRETS", "t1")).toBe(false); + expect(resolveCan(s, "CONNECT", "t1")).toBe(true); +}); diff --git a/src/services/permissions.ts b/src/services/permissions.ts index 98ecffaa1..7ccfb7877 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -21,6 +21,7 @@ export type Permission = | "EDIT_SNIPPETS"; // Bitmask values for each permission — must stay in sync with server/src/permissions.rs +// JS bitwise ops coerce to 32-bit signed, so bit 31 and above cannot be used here. export const PERM_BITS: Record = { VIEW_SECRETS: 1 << 0, // 1 COPY_SECRETS: 1 << 1, // 2 @@ -41,12 +42,30 @@ export const PERM_BITS: Record = { EDIT_SNIPPETS: 1 << 16, // 65536 }; -/** OR together all permission bits for a member's assigned roles. */ -export function effectivePermissions(member: { role_ids: string[] }, roles: TeamRole[]): number { - return member.role_ids.reduce((acc, rid) => { +export function effectivePermissions( + member: { role_ids: string[]; permission_allow?: number; permission_deny?: number }, + roles: TeamRole[], +): number { + const union = member.role_ids.reduce((acc, rid) => { const role = roles.find((r) => r.id === rid); return acc | (role?.permissions ?? 0); }, 0); + return (union | (member.permission_allow ?? 0)) & ~(member.permission_deny ?? 0); +} + +const VAULT_KEY_GATE = PERM_BITS.CONNECT | PERM_BITS.VIEW_SECRETS; + +export function crossesVaultKeyGate( + member: { role_ids: string[]; permission_allow?: number; permission_deny?: number }, + roles: TeamRole[], + next: { allow: number; deny: number }, +): boolean { + const before = effectivePermissions(member, roles) & VAULT_KEY_GATE; + const after = effectivePermissions( + { role_ids: member.role_ids, permission_allow: next.allow, permission_deny: next.deny }, + roles, + ) & VAULT_KEY_GATE; + return before !== 0 && after === 0; } /** True if member holds the builtin role with the given name in this team. */ @@ -56,6 +75,43 @@ export function hasBuiltinRole(member: TeamMember, roleName: string, roles: Team return member.role_ids.includes(target.id); } +export type MemberReadOnlyReason = "noManage" | "owner" | "self" | "higherRole" | "notHeld"; + +/** Lower position = more authority; a role absent from `roles` is skipped. */ +function minRolePosition(roleIds: string[], roles: TeamRole[]): number | null { + return roleIds.reduce((min, rid) => { + const role = roles.find((r) => r.id === rid); + if (!role) return min; + return min === null || role.position < min ? role.position : min; + }, null); +} + +/** + * One section-level reason a member's permission overrides are read-only, first + * failure wins, in the server's own guardrail order. Mirrors `assign_member_role`'s + * `(Some(_), None) => Ok(())`: an absent or roleless viewer fails closed, a roleless + * target passes. + */ +export function resolveMemberReadOnlyReason(params: { + canManageMembers: boolean; + isTargetOwner: boolean; + isMe: boolean; + viewerRoleIds: string[] | null; + targetRoleIds: string[]; + teamRoles: TeamRole[]; + offendingBits: number; +}): MemberReadOnlyReason | null { + if (!params.canManageMembers) return "noManage"; + if (params.isTargetOwner) return "owner"; + if (params.isMe) return "self"; + const viewerMin = params.viewerRoleIds ? minRolePosition(params.viewerRoleIds, params.teamRoles) : null; + const targetMin = minRolePosition(params.targetRoleIds, params.teamRoles); + const hierarchyFails = !params.viewerRoleIds || viewerMin === null || (targetMin !== null && viewerMin >= targetMin); + if (hierarchyFails) return "higherRole"; + if (params.offendingBits !== 0) return "notHeld"; + return null; +} + export interface PermissionSnapshot { myUserId: string; teams: Team[]; @@ -95,7 +151,7 @@ export function resolveCan( const myTeam = snapshot.teams.find((t) => t.id === teamId); if (!myTeam || roles.length === 0) return false; - return (effectivePermissions({ role_ids: myTeam.role_ids }, roles) & PERM_BITS[permission]) !== 0; + return (effectivePermissions(myTeam, roles) & PERM_BITS[permission]) !== 0; } /** diff --git a/src/services/teamDataManager.ts b/src/services/teamDataManager.ts index 403be9b3f..71bad8773 100644 --- a/src/services/teamDataManager.ts +++ b/src/services/teamDataManager.ts @@ -110,7 +110,7 @@ function applyFirstViewNav(teamId: string): void { const team = teams.find((t) => t.id === teamId); const roles = rolesByTeam[teamId]; if (!team || !roles || roles.length === 0) return; - const nav = firstViewNav(effectivePermissions({ role_ids: team.role_ids }, roles)); + const nav = firstViewNav(effectivePermissions(team, roles)); if (isMobileShell()) { const { tab, screen } = mobileFirstViewTarget(nav); useMobileNavStore.getState().setTab(tab); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index b3e074b76..a60cc7428 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -11,6 +11,8 @@ export interface Team { owner_tier: string; created_at: string; role_ids: string[]; + permission_allow?: number; + permission_deny?: number; } export interface TeamMember { @@ -21,6 +23,8 @@ export interface TeamMember { joined_at: string; public_key: string; role_ids: string[]; + permission_allow?: number; + permission_deny?: number; is_online?: boolean; /** An older server (no migration 035) omits this. Never render a bare "@" when absent. */ handle?: string; @@ -201,6 +205,24 @@ export async function removeMemberRole( } } +export async function setMemberPermissions( + teamId: string, + userId: string, + allow: number, + deny: number, +): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/teams/${teamId}/members/${userId}/permissions`, { + method: "PUT", + body: JSON.stringify({ allow, deny }), + }); + if (!res.ok) { + if (res.status === 403) throw new Error(i18n.t("common.error.insufficientPermissionSetMemberPermissions")); + throw new Error(i18n.t("common.error.failedToSetMemberPermissions", { status: res.status })); + } +} + // ─── Roles CRUD ─────────────────────────────────────────────────────────────── export async function listRoles(teamId: string): Promise { diff --git a/src/stores/teamStore.cacheVaultRoles.test.ts b/src/stores/teamStore.cacheVaultRoles.test.ts new file mode 100644 index 000000000..8f7267800 --- /dev/null +++ b/src/stores/teamStore.cacheVaultRoles.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PERM_BITS } from "@/services/permissions"; + +const invoke = vi.fn().mockResolvedValue(undefined); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...a: unknown[]) => invoke(...a) })); +vi.mock("@/services/teamService", () => ({ listRoles: vi.fn() })); + +describe("cacheVaultRoles", () => { + beforeEach(() => invoke.mockClear()); + + it("writes role bits minus the caller's denied bits", async () => { + const { cacheVaultRoles } = await import("@/stores/teamStore"); + await cacheVaultRoles( + [{ + id: "t1", name: "T", owner_id: "u1", owner_tier: "pro", created_at: "", + role_ids: ["r1"], permission_allow: 0, permission_deny: PERM_BITS.EDIT_CONNECTIONS, + }] as never, + { t1: [{ id: "r1", team_id: "t1", name: "editor", permissions: PERM_BITS.EDIT_CONNECTIONS | PERM_BITS.CONNECT, is_builtin: true, position: 2, created_at: "" }] }, + () => {}, + ); + + const written = JSON.parse(invoke.mock.calls[0][1].value as string); + expect(written.t1).toBe(PERM_BITS.CONNECT); + }); + + it("writes role bits plus the caller's allowed bits", async () => { + const { cacheVaultRoles } = await import("@/stores/teamStore"); + await cacheVaultRoles( + [{ + id: "t1", name: "T", owner_id: "u1", owner_tier: "pro", created_at: "", + role_ids: ["r1"], permission_allow: PERM_BITS.EDIT_KEYS, permission_deny: 0, + }] as never, + { t1: [{ id: "r1", team_id: "t1", name: "connect", permissions: PERM_BITS.CONNECT, is_builtin: true, position: 4, created_at: "" }] }, + () => {}, + ); + + const written = JSON.parse(invoke.mock.calls[0][1].value as string); + expect(written.t1).toBe(PERM_BITS.CONNECT | PERM_BITS.EDIT_KEYS); + }); +}); diff --git a/src/stores/teamStore.setMemberPermissions.test.ts b/src/stores/teamStore.setMemberPermissions.test.ts new file mode 100644 index 000000000..102f5033a --- /dev/null +++ b/src/stores/teamStore.setMemberPermissions.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PERM_BITS } from "@/services/permissions"; + +const setMemberPermissions = vi.fn().mockResolvedValue(undefined); +vi.mock("@/services/teamService", async (orig) => ({ + ...(await orig>()), + setMemberPermissions: (...a: unknown[]) => setMemberPermissions(...a), +})); + +describe("teamStore.setMemberPermissions", () => { + beforeEach(() => setMemberPermissions.mockClear()); + + it("updates the cached member optimistically", async () => { + const { useTeamStore } = await import("@/stores/teamStore"); + useTeamStore.setState({ + membersByTeam: { + t1: [{ + team_id: "t1", user_id: "u1", handle: "alice", public_key: "k", + invited_by_display_name: null, joined_at: "", role_ids: [], + permission_allow: 0, permission_deny: 0, + }], + }, + } as never); + + await useTeamStore.getState().setMemberPermissions("t1", "u1", PERM_BITS.CONNECT, PERM_BITS.VIEW_SECRETS); + + expect(setMemberPermissions).toHaveBeenCalledWith("t1", "u1", PERM_BITS.CONNECT, PERM_BITS.VIEW_SECRETS); + const member = useTeamStore.getState().membersByTeam.t1[0]; + expect(member.permission_allow).toBe(PERM_BITS.CONNECT); + expect(member.permission_deny).toBe(PERM_BITS.VIEW_SECRETS); + }); + + it("leaves the cache unchanged when the service call rejects", async () => { + const { useTeamStore } = await import("@/stores/teamStore"); + useTeamStore.setState({ + membersByTeam: { + t1: [{ + team_id: "t1", user_id: "u1", handle: "alice", public_key: "k", + invited_by_display_name: null, joined_at: "", role_ids: [], + permission_allow: 0, permission_deny: 0, + }], + }, + } as never); + setMemberPermissions.mockRejectedValueOnce(new Error("boom")); + + await expect( + useTeamStore.getState().setMemberPermissions("t1", "u1", PERM_BITS.CONNECT, PERM_BITS.VIEW_SECRETS), + ).rejects.toThrow("boom"); + + const member = useTeamStore.getState().membersByTeam.t1[0]; + expect(member.permission_allow).toBe(0); + expect(member.permission_deny).toBe(0); + }); +}); diff --git a/src/stores/teamStore.test.ts b/src/stores/teamStore.test.ts index f0434fcb0..a7dfc511a 100644 --- a/src/stores/teamStore.test.ts +++ b/src/stores/teamStore.test.ts @@ -148,6 +148,16 @@ test("loadTeams unions the bits of every role a member holds", async () => { expect(cachedRoles()).toEqual({ t1: 12 }); }); +test("loadTeams caches freshly served override masks even when nothing else changed", async () => { + const before = { ...team("t1", ["r1"]), permission_allow: 0, permission_deny: 0 }; + useTeamStore.setState({ teams: [before] }); + api.listTeams.mockResolvedValue([{ ...before, permission_deny: 8 }]); + api.listRoles.mockResolvedValue([role("r1", 12)]); + await get().loadTeams(); + expect(get().teams[0].permission_deny).toBe(8); + expect(cachedRoles()).toEqual({ t1: 4 }); +}); + test("loadTeams omits a team whose roles cannot be resolved", async () => { api.listTeams.mockResolvedValue([team("t1", ["r1"])]); api.listRoles.mockRejectedValue(new Error("offline")); diff --git a/src/stores/teamStore.ts b/src/stores/teamStore.ts index b0714ec34..efcec50c2 100644 --- a/src/stores/teamStore.ts +++ b/src/stores/teamStore.ts @@ -3,6 +3,7 @@ import { persist } from "zustand/middleware"; import { invoke } from "@tauri-apps/api/core"; import * as api from "@/services/teamService"; import { logFailure } from "@/lib/logger"; +import { effectivePermissions } from "@/services/permissions"; import type { Team, TeamMember, TeamRole, PendingInvitation, MyPendingInvitation } from "@/services/teamService"; export type { Team, TeamMember, TeamRole, PendingInvitation, MyPendingInvitation }; @@ -38,20 +39,21 @@ interface TeamStore { deleteRole: (teamId: string, roleId: string) => Promise; assignMemberRole: (teamId: string, userId: string, roleId: string) => Promise; removeMemberRole: (teamId: string, userId: string, roleId: string) => Promise; + setMemberPermissions: (teamId: string, userId: string, allow: number, deny: number) => Promise; } /** - * Mirrors {teamId -> the union of the user's role permission bits} into the - * keychain for the Rust vault-write check. Bits, not role names: a team using a - * custom-named role with write permissions would be denied locally by a name - * match even though the server allows it. The union mirrors the server's - * `bit_or` over every assigned role. + * Mirrors {teamId -> the user's effective permission bits (role union with + * allow/deny overrides applied)} into the keychain for the Rust vault-write + * check. Bits, not role names: a team using a custom-named role with write + * permissions would be denied locally by a name match even though the server + * allows it. * * A team whose roles can't be resolved is left out of the map rather than * written as "no permissions" — the server stays authoritative, and guessing * would lock the user out of a vault they can write to. */ -async function cacheVaultRoles( +export async function cacheVaultRoles( teams: Team[], rolesByTeam: Record, onRolesLoaded: (teamId: string, roles: TeamRole[]) => void, @@ -72,7 +74,7 @@ async function cacheVaultRoles( .map((rid) => roles!.find((r) => r.id === rid)?.permissions) .filter((p): p is number => typeof p === "number"); if (resolved.length < t.role_ids.length) return; - bits[t.id] = resolved.reduce((acc, p) => acc | p, 0); + bits[t.id] = effectivePermissions(t, roles!); }), ); await invoke("keychain_set", { @@ -101,6 +103,8 @@ export const useTeamStore = create()( const same = prev.length === fresh.length && fresh.every((t, i) => t.id === prev[i].id && t.name === prev[i].name && + t.permission_allow === prev[i].permission_allow && + t.permission_deny === prev[i].permission_deny && JSON.stringify(t.role_ids) === JSON.stringify(prev[i].role_ids)); const teams = same ? prev : fresh; set({ teams, loading: false }); @@ -267,6 +271,18 @@ export const useTeamStore = create()( })); }, + setMemberPermissions: async (teamId, userId, allow, deny) => { + await api.setMemberPermissions(teamId, userId, allow, deny); + set((s) => ({ + membersByTeam: { + ...s.membersByTeam, + [teamId]: (s.membersByTeam[teamId] ?? []).map((m) => + m.user_id === userId ? { ...m, permission_allow: allow, permission_deny: deny } : m, + ), + }, + })); + }, + getActiveMembers: () => { const { activeTeamId, membersByTeam } = get(); if (!activeTeamId) return [];