mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 08:02:34 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 & 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 & access.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user