feat: show out-of-scope sidebar pages to restricted members as locked rows that explain their access scope instead of hiding them

This commit is contained in:
Matthew Meszaros
2026-10-09 15:07:29 +02:00
parent 1f928de78f
commit 09ffccca80
4 changed files with 29 additions and 18 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ What a restricted member sees:
- **Inbox**: the conversations of their mailboxes, with counts and filters to match.
- **Live updates** for those resources only. They do not appear in the team's presence and do not see who else is online.
Everything that cannot yet be narrowed to a scope is unavailable to them: contacts, CRM, templates, forms, automations, deliverability, placement tests, settings other than their own profile and security, the AI assistant, and the Slack app. They are not sent workspace notifications. An API key a restricted member already holds, and an app they authorized, are held to the same scope and the same read-only routes; they cannot create new keys.
Everything that cannot yet be narrowed to a scope is unavailable to them, and shows as locked in their sidebar with the reason: contacts, CRM, templates, forms, automations, deliverability, placement tests, settings other than their own profile and security, the AI assistant, and the Slack app. They are not sent workspace notifications. An API key a restricted member already holds, and an app they authorized, are held to the same scope and the same read-only routes; they cannot create new keys.
Changing a member's access needs **Manage team** and a recent sign-in, takes effect on their next request, and applies to their open dashboard and live updates without a reload. You must hold every permission the member's roles grant, the owner always reaches the entire workspace, and you cannot change your own access. Grants on a deleted folder, campaign or mailbox disappear with it.
+6 -1
View File
@@ -56,7 +56,12 @@ test.describe("member access scope", () => {
await expect(page.getByRole("link", { name: /Q1 Outreach/ })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Contacts" })).toHaveCount(0);
await proof.chapter("Restricted teammate", "Only the granted campaigns and mailboxes, and only the pages that can be narrowed to them");
await proof.shot("restricted-campaigns", { caption: "The sidebar keeps Inbox, Campaigns and Analytics; the list holds only the granted campaigns" });
await proof.shot("restricted-campaigns", { caption: "Inbox, Campaigns and Analytics stay open, every other page is locked; the list holds only the granted campaigns" });
await page.getByRole("complementary").getByRole("button", { name: "Contacts", exact: true }).click();
await expect(page.getByText("Your access to this workspace is limited to selected campaigns and mailboxes")).toBeVisible();
await proof.shot("restricted-locked", { caption: "A locked page says the access scope is why, and who can change it" });
await page.getByRole("button", { name: "Got it" }).click();
await page.getByRole("link", { name: /RevOps outreach - July/ }).first().click();
const main = page.getByRole("main");
@@ -12,12 +12,15 @@ export default function AccessLockedDialog({
onClose,
feature,
permissionLabel = "the required",
reason = "permission",
}: {
open: boolean;
onClose: () => void;
feature: string;
/** The friendly permission name they're missing. */
permissionLabel?: string;
/** "scope" when the member is restricted to selected resources rather than missing a permission. */
reason?: "permission" | "scope";
}) {
// Portal to <body>: this dialog renders inside the sidebar <aside>, which has
// a transform (slide-in) + overflow, so a `fixed` overlay would clip to the
@@ -56,11 +59,19 @@ export default function AccessLockedDialog({
<h3 className="text-[14px] font-semibold text-slate-900">
You don't have access to {feature}
</h3>
<p className="text-[12.5px] text-slate-500 leading-relaxed mt-1.5">
Your role in this workspace doesn't include the{" "}
<span className="font-medium text-slate-700">{permissionLabel}</span> permission. Ask a
workspace admin or the owner to grant it from Settings → Roles &amp; access.
</p>
{reason === "scope" ? (
<p className="text-[12.5px] text-slate-500 leading-relaxed mt-1.5">
Your access to this workspace is limited to selected campaigns and mailboxes, and{" "}
{feature} is not part of it. Ask a workspace admin or the owner to change your access
from Settings → Members.
</p>
) : (
<p className="text-[12.5px] text-slate-500 leading-relaxed mt-1.5">
Your role in this workspace doesn't include the{" "}
<span className="font-medium text-slate-700">{permissionLabel}</span> permission. Ask a
workspace admin or the owner to grant it from Settings → Roles &amp; access.
</p>
)}
<button
type="button"
onClick={onClose}
+6 -11
View File
@@ -91,7 +91,7 @@ interface NavItem {
requires?: "inbox" | "advanced" | "subscription";
/** Role gate — when set, sidebar hides the row entirely for non-matching roles. */
rolesAllowed?: "manage";
/** Shown to a member restricted to selected resources; every other row is hidden from them. */
/** Open to a member restricted to selected resources; every other row is locked for them. */
scoped?: boolean;
/** Permission gate — when the member lacks it, the row shows a lock and a
* click pops an access dialog instead of navigating to an empty page. */
@@ -258,8 +258,6 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
const active = isNavItemActive(pathname, item);
const badge = item.badgeStoreKey === "unseenCount" ? unseen : undefined;
if (restricted && !item.scoped) return null;
// Role-gated items disappear from the sidebar for users that
// can't access them, instead of showing a lock — these are
// administrative tools, not premium features to tease.
@@ -268,7 +266,9 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
// Permission-gated items the member lacks: render a locked row that pops
// an access dialog on click, so the feature is visibly unavailable (a
// lock) rather than a blank/empty page that reads as "no data".
const accessDenied = !!item.permission && !hasItemPermission;
// A restricted member reaches only the scoped rows, whatever their role holds.
const outOfScope = restricted && !item.scoped;
const accessDenied = outOfScope || (!!item.permission && !hasItemPermission);
if (accessDenied) {
return (
<>
@@ -295,6 +295,7 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
onClose={() => setDeniedOpen(false)}
feature={item.title}
permissionLabel={item.permissionLabel ?? "the required"}
reason={outOfScope ? "scope" : "permission"}
/>
</>
);
@@ -771,14 +772,11 @@ function Section({
const toggleNavSection = useAppStore((s) => s.toggleNavSection);
const org = useAppStore((s) => s.currentOrganization);
const access = useFeatureAccess();
const restricted = useAccessRestricted();
const reduceMotion = useReducedMotion();
// Folded, a section keeps only the row you are on, in the rail and the
// full sidebar alike, so where you are never folds away with the rest.
const permitted = section.items.filter(
(item) => (item.rolesAllowed !== "manage" || access.canManage) && (!restricted || item.scoped),
);
const permitted = section.items.filter((item) => item.rolesAllowed !== "manage" || access.canManage);
const shown = folded ? permitted.filter((item) => isNavItemActive(pathname, item)) : permitted;
const hiddenSurfaces = folded
? permitted.flatMap((item) =>
@@ -791,9 +789,6 @@ function Section({
)
: [];
// A section holding nothing this member may open is not drawn at all.
if (permitted.length === 0) return null;
// In the rail a folded section with nothing left to show goes, divider and all.
const gone = collapsed && shown.length === 0;
const transition = reduceMotion ? { duration: 0 } : { duration: 0.22, ease: FOLD_EASE };