Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b0423fb
feat(perms): apply per-member allow/deny masks when resolving permiss…
kipavy Sep 11, 2026
ca37258
fix(perms): strengthen deny-precedence test, fix team-fallback masks,…
kipavy Sep 11, 2026
8fc692e
fix(vault): apply permission overrides to the keychain role mirror
kipavy Sep 11, 2026
77d6487
refactor(vault): reuse effectivePermissions for the keychain role mirror
kipavy Sep 11, 2026
e364627
feat(members): add the set-member-permissions service call and store …
kipavy Sep 11, 2026
f884fce
test(members): pin the set-member-permissions failure path
kipavy Sep 11, 2026
7a812cc
refactor(members): share one reversible-action wrapper between both r…
kipavy Sep 11, 2026
f54662c
feat(members): add a tri-state permission override row
kipavy Sep 11, 2026
088b9d0
feat(members): edit per-member permissions from the member detail panel
kipavy Sep 11, 2026
094c8f8
test(members): pin the fail-closed hierarchy guardrail
kipavy Sep 11, 2026
51ff0a1
fix(members): let an admin clear an override bit they do not hold
kipavy Sep 11, 2026
2ff6223
feat(members): mark members carrying permission overrides in the roster
kipavy Sep 11, 2026
e33a53d
fix(members): keep the role-badge wrapper inline so the roster line d…
kipavy Sep 11, 2026
978f090
feat(members): confirm before an override revokes vault key access
kipavy Sep 11, 2026
ed2bdb1
fix(members): keep rows inert while the revoke dialog is open
kipavy Sep 11, 2026
65f3c22
feat(logs): render the member permissions-changed audit event
kipavy Sep 11, 2026
55256f5
fix(teams): keep override masks in the loadTeams change predicate
kipavy Sep 11, 2026
d135081
fix(members): gate the permissions section, scope undo, keep the reti…
kipavy Sep 11, 2026
25237b5
fix(i18n): match the failedTo* format, use 保险库 for vault in zh
kipavy Sep 11, 2026
8453065
fix(members): undo bails instead of writing empty masks for an absent…
kipavy Sep 12, 2026
acfa0e3
refactor(members): extract the read-only reason chain into services/p…
kipavy Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/components/logs/AuditEventRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ const fallbackResource = () => i18n.t("logs.eventLabels.fallbackResource");
const fallbackHost = () => i18n.t("logs.eventLabels.fallbackHost");

export const ACTION_META: Record<string, ActionMeta> = {
"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 ?? "" }) },
Expand Down
11 changes: 6 additions & 5 deletions src/components/logs/AuditFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down
357 changes: 356 additions & 1 deletion src/components/members/MembersPage.MemberDetailPanel.test.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/components/members/MembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,12 +515,14 @@ const vaultTabs = selectedVaultIds.length > 1
showDetailPanel && detailMember
? (
<MemberDetailPanel
key={detailMember.user_id}
member={detailMember}
isMe={detailMember.user_id === myUserId}
teamId={teamId}
teamRoles={teamRoles}
canManageMembers={canManageMembers}
isTargetOwner={isOwnerMember(detailMember)}
viewer={myMember}
onClose={() => setShowDetailPanel(false)}
onUpdated={reload}
/>
Expand Down
222 changes: 183 additions & 39 deletions src/components/members/panels/MemberDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,75 +28,85 @@ export interface MemberDetailPanelProps {
teamRoles: TeamRole[];
canManageMembers: boolean;
isTargetOwner: boolean;
viewer?: TeamMember;
onClose: () => void;
onUpdated: () => void;
}

const READONLY_REASON_KEYS: Record<MemberReadOnlyReason, string> = {
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("");
const [toggling, setToggling] = useState<string | null>(null);
const [justToggled, setJustToggled] = useState<string | null>(null);
const [offboarding, setOffboarding] = useState<DepartMode | null>(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;
// The server rejects an owner removing themselves (teams.rs `is_owner`), so
// 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<void>;
undo: () => Promise<void>;
redo: () => Promise<void>;
}) => {
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);
Expand All @@ -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",
});
Expand Down Expand Up @@ -180,6 +284,31 @@ export function MemberDetailPanel({
)}
</FormSection>

{/* Permissions */}
{serverSupportsOverrides && (
<FormSection label={t("members.permissions.title")}>
{readOnlyReason && (
<p className="text-[10px] text-(--t-text-dim) mb-1">{readOnlyReason}</p>
)}
<div className="divide-y" style={{ borderColor: "var(--t-border)" }}>
{editablePermissions.map((permission) => {
const granting = rolesGranting(permission);
return (
<PermissionOverrideRow
key={permission}
permission={permission}
state={overrideStateOf(permission, allow, deny)}
inheritedFrom={granting}
inheritedGrants={granting.length > 0}
disabled={rowDisabled(permission)}
onChange={(next) => void handleOverride(permission, next)}
/>
);
})}
</div>
</FormSection>
)}

{/* Info */}
<FormSection label={t("members.info")}>
<div className="space-y-2 text-xs">
Expand Down Expand Up @@ -229,6 +358,21 @@ export function MemberDetailPanel({
onDone={() => { onClose(); onUpdated(); }}
/>
)}

{pendingRevoke && (
<ConfirmModal
tone="warning"
title={t("members.revokeKeyAccess.title", { name: member.handle ?? "?" })}
message={t("members.revokeKeyAccess.body", { name: member.handle ?? "?" })}
confirmLabel={t("members.revokeKeyAccess.confirm")}
onCancel={() => setPendingRevoke(null)}
onConfirm={() => {
const { permission, next } = pendingRevoke;
setPendingRevoke(null);
void commitOverride(permission, next, true);
}}
/>
)}
</>
);
}
Loading