Policies: summon the guest sign-up banner when a guest clicks a policy (#6644)

Guests (anonymous users on a login-enabled deployment) could open a
policy's setup/detail. Policies are an account feature, so a guest
clicking a policy should be nudged to sign up rather than opening it.

## Behaviour
A guest clicking a policy row — or a collapsed-rail icon — now
**re-summons the existing guest sign-up banner** ("You're using Stirling
PDF as a guest!…") and does **not** open the policy.

- `GuestUserBanner` listens for a `stirling:show-guest-banner` window
event and re-shows (even if previously dismissed; the render guard still
hides it for non-anonymous users).
- The policy sidebar dispatches that event on a guest click (cross-layer
via `CustomEvent`, same pattern as `payg:signupRequired`; a no-op on
builds without the banner).
- `usePolicyGuestBlocked()` gates it: `config.enableLogin === true &&
user.is_anonymous === true`.
- **Login-disabled single-user** deployments have an anonymous local
operator with full access → not gated.

## Verification
- Typecheck clean (proprietary + saas); eslint clean; sidebar tests
pass.

## Note
No dedicated guest unit test — the suite mocks `useAppConfig` at module
scope, and making it per-test controllable needs `vi.hoisted` plumbing
that risked the existing tests. Easy follow-up.
This commit is contained in:
Reece Browne
2026-06-12 13:10:37 +01:00
committed by GitHub
parent 511b92b321
commit 4e880c7510
2 changed files with 43 additions and 1 deletions
@@ -18,6 +18,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
import { usePolicies } from "@app/hooks/usePolicies"; import { usePolicies } from "@app/hooks/usePolicies";
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog"; import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { getPolicyAutomation } from "@app/services/policyFolders"; import { getPolicyAutomation } from "@app/services/policyFolders";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage"; import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import { runsToActivity, runsToStats } from "@app/services/policyLiveData"; import { runsToActivity, runsToStats } from "@app/services/policyLiveData";
@@ -59,6 +61,25 @@ export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED; return POLICIES_ENABLED;
} }
/**
* Whether the current user is a guest who can't open or configure policies —
* an anonymous user on a login-enabled deployment (i.e. a SaaS sign-up prompt
* candidate). The policy list stays visible but its rows don't open; the guest
* sign-up banner explains why. A login-disabled single-user deployment has an
* anonymous local operator with full access, so it is not gated.
*/
export function usePolicyGuestBlocked(): boolean {
const { config } = useAppConfig();
const { user } = useAuth();
return config?.enableLogin === true && user?.is_anonymous === true;
}
/** Re-summon the guest sign-up banner (the saas GuestUserBanner listens for this;
* a no-op on builds without it). Used when a guest clicks a gated policy. */
function promptGuestSignup(): void {
window.dispatchEvent(new CustomEvent("stirling:show-guest-banner"));
}
/** /**
* Whether a policy is open — i.e. its detail view should take over the rail in * Whether a policy is open — i.e. its detail view should take over the rail in
* place of the tool list. False when the feature is off or nothing is selected. * place of the tool list. False when the feature is off or nothing is selected.
@@ -79,6 +100,7 @@ export function PoliciesSection({
const { t } = useTranslation(); const { t } = useTranslation();
const pol = usePolicies(); const pol = usePolicies();
const { categories } = usePolicyCatalog(); const { categories } = usePolicyCatalog();
const guestBlocked = usePolicyGuestBlocked();
// Persist the expand/collapse state across refreshes. // Persist the expand/collapse state across refreshes.
const [expanded, setExpanded] = useState(() => { const [expanded, setExpanded] = useState(() => {
try { try {
@@ -179,7 +201,9 @@ export function PoliciesSection({
key={cat.id} key={cat.id}
type="button" type="button"
className="pol-row" className="pol-row"
onClick={() => selectPolicy(cat.id)} onClick={() =>
guestBlocked ? promptGuestSignup() : selectPolicy(cat.id)
}
> >
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}> <IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon} {cat.icon}
@@ -429,6 +453,7 @@ export function PoliciesCollapsedButton({
const { t } = useTranslation(); const { t } = useTranslation();
const pol = usePolicies(); const pol = usePolicies();
const { categories } = usePolicyCatalog(); const { categories } = usePolicyCatalog();
const guestBlocked = usePolicyGuestBlocked();
if (!POLICIES_ENABLED) return null; if (!POLICIES_ENABLED) return null;
@@ -468,6 +493,10 @@ export function PoliciesCollapsedButton({
{ label, status: statusLabel }, { label, status: statusLabel },
)} )}
onClick={() => { onClick={() => {
if (guestBlocked) {
promptGuestSignup();
return;
}
selectPolicy(cat.id); selectPolicy(cat.id);
onExpand(); onExpand();
}} }}
@@ -37,6 +37,19 @@ export function GuestUserBanner({ className = "" }: GuestUserBannerProps) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isAnon]); }, [isAnon]);
// Let other surfaces re-summon the banner on demand (e.g. a guest clicking a
// gated feature). Re-show even if previously dismissed; the render guard below
// still hides it for non-anonymous users.
useEffect(() => {
const show = () => {
setIsDismissed(false);
setVisible(true);
hasShownThisLoad = true;
};
window.addEventListener("stirling:show-guest-banner", show);
return () => window.removeEventListener("stirling:show-guest-banner", show);
}, []);
if (!isAnon || isDismissed || !visible) { if (!isAnon || isDismissed || !visible) {
return null; return null;
} }