diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts index dd99c21860..889bdc871e 100644 --- a/apps/web/src/lab/shell-frame.ts +++ b/apps/web/src/lab/shell-frame.ts @@ -2,7 +2,7 @@ import { Option } from "effect" import { Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type { Message as ShellMessage } from "../shell/message" -import type { Model as ShellModel } from "../shell/model" +import type { Model as ShellModel, ShellClerk } from "../shell/model" import { update as shellUpdate } from "../shell/update" import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view" @@ -30,6 +30,8 @@ export const labShellView = ( currentPath: string breadcrumbs: ReadonlyArray page: () => Html + /** Who the shell is drawn for; a lab has no Clerk session, so it hands in a fixture. */ + clerk?: ShellClerk | null }>, ): ReadonlyArray => [ h.submodel({ diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 7499d93bff..0470e67128 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -139,3 +139,31 @@ describe("URL-held right sheet", () => { ) }) }) + +describe("the shell for an analyst", () => { + const setup = [ + role("link", { name: "Sources" }), + role("link", { name: "Settings" }), + role("button", { name: "Connect" }), + ] + + test("a member has the Sources and Settings rows and the Connect button", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + ...Arr.map(setup, (it) => expect(it).toExist()), + ) + }) + + test("an analyst has none of them, and keeps their own pages", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + click(role("button", { name: "Analyst" })), + ...Arr.map(setup, (it) => expect(it).toBeAbsent()), + expect(role("link", { name: "Web analytics" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 49d780bef8..2f2df77060 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -22,7 +22,8 @@ import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" -import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" +import { init as initShell, Model as ShellModel, OnCallData, type ShellClerk } from "../shell/model" +import { NavAudience } from "../shell/nav" import type { OnCallSnapshot } from "../shell/oncall" import { foldLabShell, labShellView } from "./shell-frame" @@ -106,6 +107,7 @@ export const Model = Schema.Struct({ href: Schema.String, onCall: OnCallFixture, standOn: StandOn, + viewer: NavAudience, }) export type Model = typeof Model.Type @@ -119,6 +121,7 @@ export const Message = defineMessageUnion({ ClickedSubmitPanel: {}, ClickedOnCallFixture: { fixture: OnCallFixture }, ClickedStandOn: { standOn: StandOn }, + ClickedViewer: { viewer: NavAudience }, CompletedNavigate: {}, }) export type Message = typeof Message.Type @@ -164,6 +167,15 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ ], }) +/** A signed-in analyst with nothing else read yet: all the shell needs to pick their sidebar. */ +const ANALYST_CLERK: ShellClerk = { + maybeUser: Option.none(), + maybeOrganization: Option.none(), + memberships: [], + customer: undefined, + maybeRole: Option.some("org:analyst"), +} + /** The fixture's shifts, read on the lab's fixed clock. */ const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ ...shell, @@ -184,6 +196,7 @@ export const init = (flags: Flags): Return => { href: flags.href, onCall: "onCall", standOn: "lab", + viewer: "operator", }, commands: mapSheetCommands(sheet, toSheet), } @@ -222,6 +235,7 @@ export const update = (model: Model, message: Message): Return => model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, }), ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }), + ClickedViewer: ({ viewer }) => ({ model: { ...model, viewer } }), CompletedNavigate: () => ({ model }), }) @@ -341,6 +355,19 @@ const page = (h: HtmlBuilder, model: Model): Html => ], }), ), + control( + h, + "Viewer", + segmentedSelect(h, { + ariaLabel: "Viewer", + value: model.viewer, + onChange: (viewer) => Message.ClickedViewer({ viewer }), + options: [ + { value: "operator", label: "Member and above" }, + { value: "analyst", label: "Analyst" }, + ], + }), + ), control( h, "Current page", @@ -368,6 +395,7 @@ export const view = (model: Model, h: HtmlBuilder): Html => TooltipHost.view(h, model.tooltip, toTooltip), ...labShellView(h, model.shell, toShell, { currentPath: STAND_ON_PATH[model.standOn], + clerk: model.viewer === "analyst" ? ANALYST_CLERK : null, breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], page: () => page(h, model), }), diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index dfee746a4c..10123f69a5 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -2,6 +2,7 @@ import { InfraSurfaceLiteral } from "@maple/domain/http" import { V2Source } from "@maple/domain/http/v2" import type { BillingCustomer } from "@maple/domain/http" import type { ClerkOrganization, ClerkUser } from "../platform/clerk" +import * as Policy from "@maple/domain/policy" import { Option, Schema } from "effect" import { AsyncData } from "foldkit" import { PopupPosition } from "@maple/views/ui/anchor" @@ -15,7 +16,7 @@ import { PinnedDashboardRow } from "./dashboards-shape" import * as Palette from "./palette/model" import { Platform } from "./palette/shortcuts" import { Menu, Theme } from "./message" -import { NavSurface } from "./nav" +import { type NavAudience, NavSurface } from "./nav" import { OnCallSnapshot } from "./oncall" import { type InfraSurfaces, @@ -133,6 +134,12 @@ export type ShellClerk = Readonly<{ maybeRole: Option.Option }> +/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an analyst. */ +export const shellAudience = (clerk: ShellClerk | null): NavAudience => + clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isAnalyst([role])) + ? "analyst" + : "operator" + /** A rail item's tooltip: right of the item, centred. */ export const initRailTooltip = (): Tooltip.Model => Tooltip.init("shell-rail-tooltip", { side: "right", align: "center" }) diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index f12651beb7..8ecc8cb7af 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -62,6 +62,48 @@ describe("isNavItemActive", () => { }) }) +describe("navGroups for an analyst", () => { + const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } + /** Every page the analyst's sidebar links to: rows, then their sub-rows. */ + const hrefs = (flags: NavFlags) => + Arr.dedupe( + Arr.flatMap(navGroups(flags, "analyst"), (group) => + Arr.flatMap(group.items, (item) => [ + item.href, + ...Arr.map(item.subItems ?? [], (sub) => sub.href), + ]), + ), + ) + + test("links to what is wrong and how the product is doing, and to no setup or explorer page", () => { + expect(hrefs(off)).toEqual([ + "/", + "/alerts", + "/alerts/uptime", + "/alerts/slos", + "/errors", + "/analytics", + "/users", + "/replays", + "/dashboards", + ]) + }) + + test("adds Status pages, Releases and Cost only for an org in each rollout", () => { + expect(hrefs({ ...off, statusPages: true })).toContain("/status-pages") + expect(hrefs({ ...off, releases: true })).toContain("/releases") + expect(hrefs({ ...off, cloudCost: true })).toContain("/cost") + // Security is an engineer's page, whatever the flag says. + expect(hrefs({ ...off, securityAudit: true })).not.toContain("/security") + }) + + test("marks only Incidents on an uptime page, with its Uptime row", () => { + const items = Arr.flatMap(navGroups(off, "analyst"), (group) => group.items) + const active = Arr.filter(items, (item) => isNavItemActive("/alerts/uptime", item)) + expect(Arr.map(active, (item) => item.title)).toEqual(["Incidents"]) + }) +}) + describe("navGroups", () => { const titles = (flags: NavFlags) => Arr.flatMap(navGroups(flags), (group) => Arr.map(group.items, (item) => item.title)) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index a5c643785e..cf85977aa7 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -182,6 +182,33 @@ const alertsItem: NavItem = { ], } +/** The analyst's Alerts: what is wrong now and how reliable things are, without the setup pages. */ +const analystIncidentsItem: NavItem = { + title: "Incidents", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Incidents", + alsoActive: ["/anomalies"], + count: "alertsInbox", + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + ], +} + +/** + * Who the sidebar is drawn for. An analyst follows the product without operating it, so they get + * the pages that answer "is it working and how is it doing", not the ones for digging or setup. + */ +export const NavAudience = Schema.Literals(["operator", "analyst"]) +export type NavAudience = typeof NavAudience.Type + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ cloudCost: boolean @@ -190,8 +217,38 @@ export type NavFlags = Readonly<{ statusPages: boolean }> -/** `navGroups(flags)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a row each. */ -export const navGroups = (flags: NavFlags): ReadonlyArray => [ +const analystNavGroups = (flags: NavFlags): ReadonlyArray => [ + { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, + { + id: "reliability", + label: "Reliability", + items: [ + analystIncidentsItem, + { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, + ...(flags.statusPages + ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] + : []), + ], + }, + { + id: "product", + label: "Product", + items: [ + { title: "Web analytics", href: "/analytics", icon: "ChartBarHorizontalIcon" }, + { title: "Users", href: "/users", icon: "UsersIcon" }, + { title: "Replays", href: "/replays", icon: "PlayRotateClockwiseIcon" }, + { title: "Dashboards", href: "/dashboards", icon: "GridSquareCirclePlusIcon" }, + ...(flags.releases + ? [{ title: "Releases", href: "/releases", icon: "RocketIcon" } as const] + : []), + ...(flags.cloudCost + ? [{ title: "Cost", href: "/cost", icon: "CircleDollarIcon", badge: "Beta" } as const] + : []), + ], + }, +] + +const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "monitor", @@ -245,6 +302,13 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ }, ] +/** + * `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a + * row each, and an analyst gets {@link analystNavGroups}. + */ +export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => + audience === "analyst" ? analystNavGroups(flags) : operatorNavGroups(flags) + /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => href === "/" diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index cbcf5f8604..1ba68a5cf5 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -7,7 +7,7 @@ import { AppRoute, hrefOf } from "../../route" import type { IconName } from "@maple/views/ui/icon" import type { PinnedDashboardRow } from "../dashboards-shape" import type { Theme } from "../message" -import { navGroups } from "../nav" +import { type NavAudience, type NavGroup, navGroups } from "../nav" // The palette's entries. @@ -64,29 +64,58 @@ export const FLAGGED_HREFS: ReadonlySet = new Set([ const icon = (name: IconName): Glyph => ({ _tag: "Icon", name }) const href = (url: string): Target => ({ _tag: "Href", href: url }) -/** Every section, its children and their views, deduplicated by title and href. */ -const navigationEntries = (): ReadonlyArray => { - const rows = Arr.flatMap( - navGroups({ cloudCost: true, releases: true, securityAudit: true, statusPages: true }), - (group) => - Arr.flatMap(group.items, (item) => [ - { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, - ...Arr.flatMap(item.subItems ?? [], (sub) => [ - { - id: `nav:${item.title}:${sub.title}`, - title: sub.paletteTitle ?? sub.title, - href: sub.href, - icon: sub.icon ?? item.icon, - }, - ...Arr.map(sub.views ?? [], (view) => ({ - id: `nav:${item.title}:${sub.title}:${view.title}`, - title: view.paletteTitle ?? `${sub.title} ${view.title}`, - href: view.href, - icon: view.paletteIcon ?? sub.icon ?? item.icon, - })), - ]), +const EVERY_FLAG = { cloudCost: true, releases: true, securityAudit: true, statusPages: true } + +/** A page's palette row under each title the nav gives it: the section, its children, their views. */ +const navRowsOf = (groups: ReadonlyArray) => + Arr.flatMap(groups, (group) => + Arr.flatMap(group.items, (item) => [ + { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, + ...Arr.flatMap(item.subItems ?? [], (sub) => [ + { + id: `nav:${item.title}:${sub.title}`, + title: sub.paletteTitle ?? sub.title, + href: sub.href, + icon: sub.icon ?? item.icon, + }, + ...Arr.map(sub.views ?? [], (view) => ({ + id: `nav:${item.title}:${sub.title}:${view.title}`, + title: view.paletteTitle ?? `${sub.title} ${view.title}`, + href: view.href, + icon: view.paletteIcon ?? sub.icon ?? item.icon, + })), ]), + ]), ) + +/** What an analyst's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ +const ANALYST_TITLES: ReadonlyMap = new Map([ + ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "analyst")), (row) => [row.href, row.title] as const), + ["/account", "Account"] as const, +]) + +/** + * The entries as the viewer's palette lists them. An analyst gets their own pages under their + * sidebar's names, dashboards and the app actions; the ids stay, so a pick resolves either way. + */ +export const entriesFor = ( + entries: ReadonlyArray, + audience: NavAudience, +): ReadonlyArray => + audience === "operator" + ? entries + : Arr.flatMap(entries, (entry): ReadonlyArray => { + if (entry.group === "Actions" || entry.group === "Dashboards") return [entry] + if (entry.group !== "Navigation" || entry.target._tag !== "Href") return [] + const title = ANALYST_TITLES.get(entry.target.href) + return title === undefined + ? [] + : [{ ...entry, title, keywords: `${entry.keywords} ${title}` }] + }) + +/** Every section, its children and their views, deduplicated by title and href. */ +const navigationEntries = (): ReadonlyArray => { + const rows = navRowsOf(navGroups(EVERY_FLAG)) // One row per page, titled by its most specific row ("Traces", not its "Explore" parent); // every title that opens the same page stays searchable as a keyword. const unique = Arr.reverse(Arr.dedupeWith(Arr.reverse(rows), (a, b) => a.href === b.href)) diff --git a/apps/web/src/shell/palette/global-shortcuts.test.ts b/apps/web/src/shell/palette/global-shortcuts.test.ts index 25553890eb..1ed9b8ac96 100644 --- a/apps/web/src/shell/palette/global-shortcuts.test.ts +++ b/apps/web/src/shell/palette/global-shortcuts.test.ts @@ -59,7 +59,12 @@ describe("global shortcuts", () => { const opened = step(signedIn(), Message.ClickedSearch()).model const entered = step( opened, - toPalette(Palette.Message.PressedEnter({ visibleFlagged: ["/releases", "/security"] })), + toPalette( + Palette.Message.PressedEnter({ + visibleFlagged: ["/releases", "/security"], + audience: "operator", + }), + ), ) expect(entered.outMessage).toEqual({ _tag: "RequestedNavigation", url: "/" }) }) diff --git a/apps/web/src/shell/palette/model.ts b/apps/web/src/shell/palette/model.ts index 91825017b2..96d1d42899 100644 --- a/apps/web/src/shell/palette/model.ts +++ b/apps/web/src/shell/palette/model.ts @@ -2,6 +2,7 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" +import { NavAudience } from "../nav" import { PaletteAction } from "./entries" // The command palette: mounted while open, so @@ -32,7 +33,7 @@ export const Message = defineMessageUnion({ /** A pick: a click on an entry or Enter on the highlighted one (`items` in list order). */ ClickedEntry: { id: Schema.String }, /** Enter in the input; the view knows whether the org shows Releases. */ - PressedEnter: { visibleFlagged: Schema.Array(Schema.String) }, + PressedEnter: { visibleFlagged: Schema.Array(Schema.String), audience: NavAudience }, SucceededLoadServices: { serviceNames: Schema.Array(Schema.String) }, FailedLoadServices: {}, CompletedReleaseServices: {}, diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index 59cfda351c..2830cc59ed 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -106,7 +106,7 @@ describe("command palette", () => { const model = typed(open().model, "logs").model const pressed = update( model, - Message.PressedEnter({ visibleFlagged: ["/releases", "/security"] }), + Message.PressedEnter({ visibleFlagged: ["/releases", "/security"], audience: "operator" }), context, ) expect(pressed.outMessage).toEqual(OutMessage.Navigated({ href: "/logs" })) @@ -151,6 +151,40 @@ describe("command palette", () => { expect(titles).toEqual(expect.arrayContaining(["Connect Slack", "Connect Discord"])) }) + test("an analyst finds their own pages and dashboards, not setup or the explorers", () => { + const model = loadedWith(["storefront"]) + const shown = (query: string) => + Arr.map( + shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "analyst").entries, + (entry) => entry.title, + ) + expect(shown("analytics")).toContain("Web analytics") + // Their sidebar calls the alerts inbox Incidents, so that is what they search for and see. + expect(shown("incidents")).toContain("Incidents") + expect(shown("alerts")).not.toContain("Alerts") + expect(shown("checkout")).toContain("Checkout health") + expect(shown("traces")).not.toContain("Traces") + expect(shown("connect")).toEqual([]) + expect(shown("storefront")).not.toContain("storefront") + expect(shown("settings")).not.toContain("Settings") + // The same queries still find them for everyone else. + const operator = Arr.map( + shownEntries(typed(model, "traces").model, context, ALL_FLAGGED, "operator").entries, + (entry) => entry.title, + ) + expect(operator).toContain("Traces") + }) + + test("Enter opens the page an analyst sees highlighted, not the full list's", () => { + const model = typed(loadedWith([]), "uptime").model + const result = update( + model, + Message.PressedEnter({ visibleFlagged: [], audience: "analyst" }), + context, + ) + expect(result.outMessage).toEqual(OutMessage.Navigated({ href: "/alerts/uptime" })) + }) + test("a flagged-off row is not shown", () => { const titles = (visibleFlagged: ReadonlyArray) => Arr.map(shownEntries(open().model, context, visibleFlagged).entries, (entry) => entry.title) diff --git a/apps/web/src/shell/palette/update.ts b/apps/web/src/shell/palette/update.ts index 8c3decf94c..5ec54f2993 100644 --- a/apps/web/src/shell/palette/update.ts +++ b/apps/web/src/shell/palette/update.ts @@ -4,8 +4,10 @@ import Fuse from "fuse.js" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../platform/resources" +import type { NavAudience } from "../nav" import { browseEntries, + entriesFor, type EntryInputs, groupEntries, MAX_RESULTS, @@ -61,8 +63,9 @@ const computeShownEntries = ( model: Model, context: Context, visibleFlagged: ReadonlyArray, + audience: NavAudience, ): Shown => { - const all = entriesOf(model, context) + const all = entriesFor(entriesOf(model, context), audience) const entries = Arr.filter( all, (entry) => @@ -84,7 +87,7 @@ const sameItems = (a: ReadonlyArray, b: ReadonlyArray) => type ShownInputs = Readonly<{ query: string; serviceNames: ReadonlyArray }> & Context & - Readonly<{ visibleFlagged: ReadonlyArray }> + Readonly<{ visibleFlagged: ReadonlyArray; audience: NavAudience }> const sameInputs = (a: ShownInputs, b: ShownInputs) => a.query === b.query && @@ -92,7 +95,8 @@ const sameInputs = (a: ShownInputs, b: ShownInputs) => sameItems(a.serviceNames, b.serviceNames) && sameItems(a.dashboards, b.dashboards) && sameItems(a.favorites, b.favorites) && - sameItems(a.visibleFlagged, b.visibleFlagged) + sameItems(a.visibleFlagged, b.visibleFlagged) && + a.audience === b.audience /** Every render rebuilds the context, so the last answer is kept by its inputs, not by reference. */ let lastShown: { inputs: ShownInputs; shown: Shown } | null = null @@ -101,23 +105,30 @@ export const shownEntries = ( model: Model, context: Context, visibleFlagged: ReadonlyArray, + audience: NavAudience = "operator", ): Shown => { const inputs: ShownInputs = { ...context, query: model.list.inputValue, serviceNames: model.serviceNames, visibleFlagged, + audience, } if (lastShown !== null && sameInputs(lastShown.inputs, inputs)) return lastShown.shown - const shown = computeShownEntries(model, context, visibleFlagged) + const shown = computeShownEntries(model, context, visibleFlagged, audience) lastShown = { inputs, shown } return shown } /** The entries in list order (grouped), what the highlight indexes. */ -const listedEntries = (model: Model, context: Context, visibleFlagged: ReadonlyArray) => +const listedEntries = ( + model: Model, + context: Context, + visibleFlagged: ReadonlyArray, + audience: NavAudience, +) => Arr.flatMap( - groupEntries(shownEntries(model, context, visibleFlagged).entries), + groupEntries(shownEntries(model, context, visibleFlagged, audience).entries), (section) => section.entries, ) @@ -143,8 +154,13 @@ const toList = Command.mapMessage((message: Combobox.Message) => Message.GotList const toDialog = Command.mapMessage((message: Dialog.Message) => Message.GotDialogMessage({ message })) /** Base UI's Enter: the highlighted entry is picked. */ -const enter = (model: Model, context: Context, visibleFlagged: ReadonlyArray): UpdateReturn => - picked(model, listedEntries(model, context, visibleFlagged)[model.list.highlighted ?? -1]) +const enter = ( + model: Model, + context: Context, + visibleFlagged: ReadonlyArray, + audience: NavAudience, +): UpdateReturn => + picked(model, listedEntries(model, context, visibleFlagged, audience)[model.list.highlighted ?? -1]) /** The inline autocomplete: typing searches, arrows move the highlight into view, Enter picks. */ const listStep = (model: Model, child: Combobox.Message, context: Context): UpdateReturn => { @@ -176,7 +192,7 @@ export const update = (model: Model, message: Message, context: Context): Update } }, GotListMessage: ({ message: child }) => listStep(model, child, context), - PressedEnter: ({ visibleFlagged }) => enter(model, context, visibleFlagged), + PressedEnter: ({ visibleFlagged, audience }) => enter(model, context, visibleFlagged, audience), ClickedEntry: ({ id }) => { const entry = findEntry(model, context, id) // A link's own click navigates (the root routes it), so only the palette closes. diff --git a/apps/web/src/shell/palette/view.ts b/apps/web/src/shell/palette/view.ts index a5d683bbf6..df9b5316a9 100644 --- a/apps/web/src/shell/palette/view.ts +++ b/apps/web/src/shell/palette/view.ts @@ -16,6 +16,7 @@ import { kbd } from "@maple/views/ui/display" import { icon } from "@maple/views/ui/icon" import { manifestGlyph } from "@maple/views/ui/manifest-glyph" import { serviceDot } from "@maple/views/ui/values" +import type { NavAudience } from "../nav" import { type Glyph, groupEntries, type PaletteEntry } from "./entries" import { Message, type Model } from "./model" import { type Context, shownEntries } from "./update" @@ -32,7 +33,11 @@ const PALETTE_LIST_CLASS = // The command palette's popup: the input, the grouped entries (or "No results"), the footer hints. -export type ViewInputs = Readonly<{ context: Context; visibleFlagged: ReadonlyArray }> +export type ViewInputs = Readonly<{ + context: Context + visibleFlagged: ReadonlyArray + audience: NavAudience +}> const GLYPH_CLASS = "shrink-0 text-muted-foreground" @@ -81,10 +86,10 @@ export const paletteView = ( const toList = (message: Combobox.Message) => toMessage( message._tag === "PressedInputKey" && message.key === "Enter" - ? Message.PressedEnter({ visibleFlagged: inputs.visibleFlagged }) + ? Message.PressedEnter({ visibleFlagged: inputs.visibleFlagged, audience: inputs.audience }) : Message.GotListMessage({ message }), ) - const shown = shownEntries(model, inputs.context, inputs.visibleFlagged) + const shown = shownEntries(model, inputs.context, inputs.visibleFlagged, inputs.audience) const groups = groupsOf(h, shown.entries) return h.submodel({ slotId: "command-palette", diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 120e133a2c..2825a186b8 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -21,7 +21,7 @@ import { } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" -import { type Model, type ShellClerk, shellProbe, shellSurfaces } from "./model" +import { type Model, type ShellClerk, shellAudience, shellProbe, shellSurfaces } from "./model" import { isNavItemActive, matchSubItem, @@ -43,18 +43,22 @@ import { typography } from "@maple/views/ui/typography" * Security also needs a role with `security:manage`: members never see it. */ export const shellNavGroups = (clerk: ShellClerk | null = null): ReadonlyArray => { + const audience = shellAudience(clerk) const flag = (name: keyof NavFlags) => !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeOrganization, (organization) => organization.flags[name])) const canReadSecurity = !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.can([role], "security:manage"))) - return navGroups({ - cloudCost: flag("cloudCost"), - releases: flag("releases"), - securityAudit: flag("securityAudit") && canReadSecurity, - statusPages: flag("statusPages"), - }) + return navGroups( + { + cloudCost: flag("cloudCost"), + releases: flag("releases"), + securityAudit: flag("securityAudit") && canReadSecurity, + statusPages: flag("statusPages"), + }, + audience, + ) } /** The last-hour probe's answer; `null` while it is first in flight or after it failed. */ diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 2582590afd..1ec2c911d0 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -19,7 +19,7 @@ import { } from "@maple/views/ui/sidebar" import { isSameMenu, triggerIdOf } from "./menus" import { Menu, Message } from "./message" -import type { Model, ShellClerk } from "./model" +import { type Model, type ShellClerk, shellAudience } from "./model" import * as ClerkOrgSwitcherView from "./clerk-org-switcher-view" import { orgAvatar } from "./clerk-org-switcher-view" import { typography } from "@maple/views/ui/typography" @@ -405,14 +405,29 @@ export const shellSidebarFooter = ( sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), - footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), - // `/integrations` renders inside the settings rail, so Settings owns it. - footerNavRow( - h, - currentPath, - { href: "/settings", title: "Settings", icon: "GearIcon", alsoActive: "/integrations" }, - tip, - ), + // An analyst sets nothing up: their account is in the user menu below. + ...(shellAudience(clerk) === "analyst" + ? [] + : [ + footerNavRow( + h, + currentPath, + { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, + tip, + ), + // `/integrations` renders inside the settings rail, so Settings owns it. + footerNavRow( + h, + currentPath, + { + href: "/settings", + title: "Settings", + icon: "GearIcon", + alsoActive: "/integrations", + }, + tip, + ), + ]), ]), h.div( [h.Class("-mx-2 border-sidebar-border border-t px-2 pt-2")], diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index 0e5f36cf29..db0784c776 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -18,7 +18,7 @@ import { placementOf, popupIdOf, triggerIdOf } from "./menus" import { billingBanners } from "./billing-banners" import * as SupportChannelView from "./support-channel-view" import { Message } from "./message" -import type { Model, ShellClerk } from "./model" +import { type Model, type ShellClerk, shellAudience } from "./model" import { MeasureSidebarOverflow, PositionMenu } from "./mounts" import { isMenuKey } from "./update" import { menuContent } from "./view-menus" @@ -68,8 +68,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), - "Sources", - "Settings", + ...(shellAudience(clerk) === "analyst" ? [] : ["Sources", "Settings"]), ] } @@ -196,7 +195,8 @@ export const frameView = Submodel.defineView((model inputs.breadcrumbs, inputs.topbarActions(), railTriggers, - !hidesTopbarConnect(inputs.currentPath), + !hidesTopbarConnect(inputs.currentPath) && + shellAudience(inputs.clerk ?? null) !== "analyst", inputs.sourceLink ?? null, ), ...(inputs.clerk === undefined || inputs.clerk === null @@ -301,6 +301,7 @@ export const paletteLayerView = Submodel.defineView Arr.flatMap(group.items, (item) => FLAGGED_HREFS.has(item.href) ? [item.href] : [],