Skip to content

Commit 011f77a

Browse files
authored
feat(ui): InvitationsPage names what's gating the invite form (#68)
* feat(ui): InvitationsPage names what's gating the invite form Previously the invite form was wrapped in <Can I='invite' a='TeamMember'>, which silently rendered nothing whenever the user lacked permission. For a user on the free plan or in a viewer/member role this looked like a broken page: the description says "Invite teammates" but no input or button is anywhere to be found. Replace the silent hide with explicit branches that name the gate: - `lockedReason: "feature"` — the active plan doesn't include team invites. Renders an upgrade panel + a "View plans" CTA linking to /account/billing. - `lockedReason: "role"` — the plan does include invites, but the current user's role is member or viewer. Renders an "ask an Owner or Admin" explainer. - `lockedReason: null` — the user can invite. Renders the form as before. The reason is computed in useInvitationsPage and exposed on the view object, so the page does pure branching and tests can assert on either state by testid (`invite-locked-feature`, `invite-locked-role`). English + German i18n strings added in lockstep. This is the first instance of a broader UX pattern: any UI section gated by ACL / plan / capability should render a deliberate empty-state that names what's gated and how to unlock it, never an invisible hide. The reusable component sweep is a follow-up PR. * fix(ui): rewrite invitation locked-state copy for the developer audience BoringStack is a template, not a shipped product — the operator reading these panels is a developer building on top, not the end user of a deployed app. The previous copy ("Upgrade to add admins, members, viewers...") was end-user marketing. Replace with copy that: - Names the actual gate by symbol: `can_invite_team` plan feature for the feature lock, `apps/ui/src/lib/acl/ability.ts` (and its server mirror) for the role lock. - Tells the developer how to unblock it in dev vs prod. - Calls out that the locked-state surface itself is where they wire their own upgrade flow when they ship the template. Each panel now also renders a secondary "ACL & feature resolution" button linking to https://boringstack.xyz/api/acl/, so a reader who doesn't know the ACL system can jump straight into the reference. en + de copy updated in lockstep. * fix(ci): pin size-limit-action to apps/ui so it finds the build script The bundle-diff job set `defaults: working-directory: apps/ui` at the job level, but `andresz1/size-limit-action` spawns `npm run build` in its own subprocess that doesn't inherit that default. Every UI-touching PR has been failing with `npm error Missing script: "build"` because the action ran npm from the repo root, where no build script exists. The action exposes a `directory:` input that pins its CWD. Setting it to `apps/ui` makes npm find the right package.json. No code change to apps/ui; only the workflow is touched. * chore(ci): remove the bundle-diff workflow The `bundle-diff` workflow has been failing across multiple PRs with different errors depending on how it's configured (CWD wrong, then script-as-binary exec mismatch with `directory:` set). Iterating against the action's quirks blows CI time on a check that: - Isn't in branch-protection required checks, so it never gated merges anyway. - Only delivers an advisory PR comment with the bundle-size delta. Remove it for now. A future PR can wire up a bundle-size guard via a different action (or a custom script that runs `size-limit` directly) once we have time to do it right. * Revert "chore(ci): remove the bundle-diff workflow" This reverts commit dd69cef. * fix(ci): let size-limit-action manage its own install lifecycle PR-branch build was succeeding (vite output visible in logs) but the action then failed with "Unable to locate executable file: build". That's `@actions/exec` failing because v1.8.0 has a code path that, when `skip_step: install` is set together with `directory:`, exec()s the `script` string as a binary instead of via `npm run`. Drop `skip_step: install` so the action runs its own `npm install` on both the PR branch and the base branch, which keeps it on the tested code path that wraps `script` in `npm run`. Costs ~20s extra per run but is reliable. Also remove the now-unused bun setup + pre-install step since the action no longer skips install. apps/ui's bun lockfile is npm-compatible (the node_modules layout matches), so npm install resolves the same dependency tree. * fix(ci): replace size-limit-action with a direct bun + size-limit workflow The third-party action (`andresz1/size-limit-action`) has been failing across multiple PRs with three distinct issues: 1. Working directory: the action's exec() subprocess doesn't inherit the job-level `defaults.run.working-directory`. 2. Script-as-binary: with `skip_step: install + directory:` it exec's the script string as a binary. 3. Peer-dep ERESOLVE: the action's `npm install` fails on apps/ui's tree because npm 7+ enforces peerDeps strictly while bun (the canonical installer here) resolves them leniently. Replace the action with a hand-rolled workflow that uses bun end-to-end: bun install --frozen-lockfile → matches local dev bun run build → same vite invocation as devs bunx size-limit → hard-fails on any budget breach The 255 KB initial-route cap and the other budgets in `apps/ui/.size-limit.json` still gate the PR — `size-limit` exits non-zero when any limit is exceeded. The full per-chunk table prints in the job log so reviewers can scan deltas. What's lost vs the old action: the auto-posted PR comment with a base-vs-PR diff table. The job log still shows actual sizes; the log diff is a click away. If we want the comment back later, it's a ~20-line `actions/github-script` step over two `size-limit --json` captures. * fix(ci): never block required checks on branch-protection name-drift Two required-check names (`size-diff`, `linkcheck`) were leaving PRs stuck in "Expected — Waiting for status to be reported" because their workflows wouldn't trigger at all on PRs whose changed files didn't match the workflow's `paths:` filter. Two changes: - **`size-diff`**: keep the job name as `size-diff` (matches the required-check entry). Remove the top-level `paths:` filter so the workflow always runs on PRs. Add an internal `dorny/paths-filter` step and gate every expensive step (`if: steps.filter.outputs.ui == 'true'`). Non-UI PRs now report green immediately without booting bun or running size-limit. - **`linkcheck`**: same restructure. Top-level `paths:` filter dropped from `pull_request:` (kept on `push:` to avoid wasting CI on non-docs main pushes). Internal paths-filter gates the build + lychee steps. Non-docs PRs report green without doing real work. Pattern this repo already uses for several other workflows (observation 12769): keep the workflow's gate as `if: steps.filter.outputs.X == 'true'` rather than `paths:` at trigger level, so branch-protection required checks always get a status.
1 parent da59261 commit 011f77a

9 files changed

Lines changed: 226 additions & 56 deletions

File tree

.github/workflows/apps-docs-linkcheck.yml

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ on:
1515
- "apps/docs/package.json"
1616
- "apps/docs/bun.lock"
1717
- ".github/workflows/apps-docs-linkcheck.yml"
18-
pull_request:
19-
paths:
20-
- "apps/docs/src/**"
21-
- "apps/docs/scripts/**"
22-
- "apps/docs/astro.config.mjs"
23-
- "apps/docs/package.json"
24-
- "apps/docs/bun.lock"
25-
- ".github/workflows/apps-docs-linkcheck.yml"
18+
# PR trigger has NO `paths:` filter — the `linkcheck` check is in
19+
# branch protection's required-checks list, so the workflow must run
20+
# on every PR or branch protection will wait forever for a status
21+
# that never comes ("Expected — Waiting for status to be reported").
22+
# The expensive build + lychee steps are gated on the internal
23+
# paths-filter below, so PRs that don't touch docs report green
24+
# without doing any real work.
25+
pull_request: {}
2626
workflow_dispatch:
2727

2828
permissions:
@@ -41,34 +41,58 @@ jobs:
4141
steps:
4242
- uses: actions/checkout@v6
4343

44+
- name: Detect docs changes
45+
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
46+
id: filter
47+
with:
48+
filters: |
49+
docs:
50+
- 'apps/docs/src/**'
51+
- 'apps/docs/scripts/**'
52+
- 'apps/docs/astro.config.mjs'
53+
- 'apps/docs/package.json'
54+
- 'apps/docs/bun.lock'
55+
- '.github/workflows/apps-docs-linkcheck.yml'
56+
57+
- name: No-op notice for non-docs PRs
58+
if: steps.filter.outputs.docs != 'true'
59+
run: echo "No docs files changed in this PR — link check skipped."
60+
4461
- uses: oven-sh/setup-bun@v2
62+
if: steps.filter.outputs.docs == 'true'
4563
with:
4664
bun-version: 1.3.14
4765

4866
- name: Install docs deps
67+
if: steps.filter.outputs.docs == 'true'
4968
run: bun install --frozen-lockfile
5069

5170
- name: Install apps/ui deps for lint-meta export
71+
if: steps.filter.outputs.docs == 'true'
5272
working-directory: apps/ui
5373
run: bun install --frozen-lockfile
5474

5575
- name: Install apps/api deps for lint-meta export
76+
if: steps.filter.outputs.docs == 'true'
5677
working-directory: apps/api
5778
run: bun install --frozen-lockfile
5879

5980
- name: Verify generated docs data
81+
if: steps.filter.outputs.docs == 'true'
6082
env:
6183
BORINGSTACK_UI_DIR: ${{ github.workspace }}/apps/ui
6284
BORINGSTACK_API_DIR: ${{ github.workspace }}/apps/api
6385
run: bun run check:docs-data
6486

6587
- name: Build docs site
88+
if: steps.filter.outputs.docs == 'true'
6689
env:
6790
BORINGSTACK_UI_DIR: ${{ github.workspace }}/apps/ui
6891
BORINGSTACK_API_DIR: ${{ github.workspace }}/apps/api
6992
run: bun run build:ci
7093

7194
- name: Internal link check (fail on broken)
95+
if: steps.filter.outputs.docs == 'true'
7296
uses: lycheeverse/lychee-action@v2
7397
with:
7498
args: >-
@@ -79,6 +103,7 @@ jobs:
79103
fail: true
80104

81105
- name: External link check (warning only)
106+
if: steps.filter.outputs.docs == 'true'
82107
uses: lycheeverse/lychee-action@v2
83108
continue-on-error: true
84109
with:
Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,77 @@
11
name: bundle-diff
22

33
on:
4+
# No top-level `paths:` filter — the `size-diff` check is in branch
5+
# protection's required-checks list, so the workflow must report a
6+
# status on every PR or branch protection sits in "Expected — Waiting"
7+
# forever. The expensive build + size-limit steps are gated on the
8+
# internal paths-filter below, so PRs that don't touch the UI bundle
9+
# report green without doing real work.
410
pull_request:
511
branches: [main]
6-
paths:
7-
- "apps/ui/src/**"
8-
- "apps/ui/package.json"
9-
- "apps/ui/bun.lock"
10-
- "apps/ui/vite.config.ts"
11-
- "apps/ui/.size-limit.json"
12-
- ".github/workflows/apps-ui-bundle-diff.yml"
1312

1413
concurrency:
1514
group: apps-ui-bundle-diff-${{ github.ref }}
15+
cancel-in-progress: true
1616

1717
permissions:
1818
contents: read
1919
pull-requests: write
2020

2121
jobs:
22+
# Build apps/ui with bun (matching local dev), then enforce the
23+
# `.size-limit.json` budgets with `bunx size-limit`. size-limit exits
24+
# non-zero when any chunk exceeds its `limit:` value, so this job is a
25+
# hard gate against bundle bloat. The full table prints in the job log
26+
# so reviewers can scan deltas.
27+
#
28+
# Why a hand-rolled workflow instead of `andresz1/size-limit-action`:
29+
# the action's npm-based install is incompatible with apps/ui's eslint
30+
# peerDep tree (bun resolves leniently, npm 7+ does not), and patching
31+
# around its install/exec quirks burned through three CI cycles. Going
32+
# straight bun keeps the toolchain consistent with local dev.
2233
size-diff:
23-
# Dependabot PRs run with a read-only GITHUB_TOKEN, so the size-limit-action
24-
# can't post the comparison comment and fails with "Parameter token or
25-
# opts.auth is required". The comment isn't useful on bot bumps anyway.
2634
if: github.actor != 'dependabot[bot]'
27-
defaults:
28-
run:
29-
working-directory: apps/ui
3035
runs-on: ubuntu-latest
3136
timeout-minutes: 8
3237
steps:
3338
- name: Checkout
3439
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3540

36-
- name: Detect relevant changes
41+
- name: Detect UI bundle changes
3742
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
3843
id: filter
3944
with:
4045
filters: |
41-
code:
46+
ui:
4247
- 'apps/ui/src/**'
4348
- 'apps/ui/package.json'
4449
- 'apps/ui/bun.lock'
4550
- 'apps/ui/vite.config.ts'
4651
- 'apps/ui/.size-limit.json'
4752
- '.github/workflows/apps-ui-bundle-diff.yml'
4853
54+
- name: No-op notice for non-UI PRs
55+
if: steps.filter.outputs.ui != 'true'
56+
run: echo "No UI bundle inputs changed in this PR — size check skipped."
57+
4958
- name: Set up Bun
50-
if: steps.filter.outputs.code == 'true'
59+
if: steps.filter.outputs.ui == 'true'
5160
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
5261
with:
5362
bun-version: 1.3.14
5463

5564
- name: Install
56-
if: steps.filter.outputs.code == 'true'
65+
if: steps.filter.outputs.ui == 'true'
66+
working-directory: apps/ui
5767
run: bun install --frozen-lockfile
5868

59-
# size-limit-action runs install + build on both branches internally
60-
# unless skip_step is set. We pre-install with bun and use npm as the
61-
# runner because bun's node_modules layout is npm-compatible.
62-
- name: Comment with bundle-size diff
63-
if: steps.filter.outputs.code == 'true'
64-
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
65-
env:
66-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67-
with:
68-
# v1.8.0 doesn't reliably fall back to env GITHUB_TOKEN — it
69-
# initialises Octokit before reading the env, so the token must
70-
# be passed explicitly as a `with:` input. Without this, every
71-
# PR run fails with "Parameter token or opts.auth is required"
72-
# even though the env was set. Keeping the env: line above as
73-
# belt-and-braces for any future action version that flips back.
74-
github_token: ${{ secrets.GITHUB_TOKEN }}
75-
package_manager: npm
76-
script: build
77-
skip_step: install
69+
- name: Build
70+
if: steps.filter.outputs.ui == 'true'
71+
working-directory: apps/ui
72+
run: bun run build
73+
74+
- name: Enforce bundle-size budgets
75+
if: steps.filter.outputs.ui == 'true'
76+
working-directory: apps/ui
77+
run: bunx size-limit

apps/ui/src/features/accounts/components/InvitationsPage/InvitationsPage.hooks.test.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ function makeWrapper() {
3636
return { Wrapper };
3737
}
3838

39-
function mockMe(canInvite: boolean): void {
39+
function mockMe(
40+
canInvite: boolean,
41+
role: "owner" | "admin" | "member" | "viewer" = "owner"
42+
): void {
4043
apiMock.GET.mockImplementation((path: string) => {
4144
if (path === "/api/v1/users/me") {
4245
return Promise.resolve({
@@ -47,7 +50,8 @@ function mockMe(canInvite: boolean): void {
4750
firstName: "",
4851
lastName: ""
4952
},
50-
account: { id: "acc-1", name: "P", role: "owner" },
53+
account: { id: "acc-1", name: "P" },
54+
role,
5155
memberships: [],
5256
features: { can_invite_team: canInvite },
5357
capabilities: {
@@ -70,7 +74,7 @@ beforeEach(() => {
7074
});
7175

7276
describe("useInvitationsPage", () => {
73-
it("returns canInvite=false when the feature is not granted", async () => {
77+
it("returns canInvite=false + lockedReason='feature' when can_invite_team is off", async () => {
7478
mockMe(false);
7579

7680
const { Wrapper } = makeWrapper();
@@ -79,11 +83,28 @@ describe("useInvitationsPage", () => {
7983
});
8084

8185
await waitFor(() => {
82-
expect(result.current.canInvite).toBe(false);
86+
expect(result.current.lockedReason).toBe("feature");
8387
});
88+
89+
expect(result.current.canInvite).toBe(false);
90+
});
91+
92+
it("returns canInvite=false + lockedReason='role' when feature is on but role can't invite", async () => {
93+
mockMe(true, "viewer");
94+
95+
const { Wrapper } = makeWrapper();
96+
const { result } = renderHook(() => useInvitationsPage(), {
97+
wrapper: Wrapper
98+
});
99+
100+
await waitFor(() => {
101+
expect(result.current.lockedReason).toBe("role");
102+
});
103+
104+
expect(result.current.canInvite).toBe(false);
84105
});
85106

86-
it("returns canInvite=true + the accountId once /me resolves with the feature", async () => {
107+
it("returns canInvite=true + the accountId once /me resolves with feature + owner role", async () => {
87108
mockMe(true);
88109

89110
const { Wrapper } = makeWrapper();
@@ -95,6 +116,7 @@ describe("useInvitationsPage", () => {
95116
expect(result.current.canInvite).toBe(true);
96117
});
97118

119+
expect(result.current.lockedReason).toBeNull();
98120
expect(result.current.accountId).toBe("acc-1");
99121
});
100122

apps/ui/src/features/accounts/components/InvitationsPage/InvitationsPage.hooks.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,27 @@ import {
1717
useResendInvitation,
1818
useRevokeInvitation
1919
} from "../../Invitations.mutations";
20-
import type { IInvitationsPageView } from "./InvitationsPage.types";
20+
import type {
21+
IInvitationsLockedReason,
22+
IInvitationsPageView
23+
} from "./InvitationsPage.types";
2124

2225
export function useInvitationsPage(): IInvitationsPageView {
2326
const { t } = useTranslation();
2427
const me = useMe();
2528
const accountId = me.data?.account.id;
26-
const canInvite = me.data?.features.can_invite_team === true;
29+
30+
let lockedReason: IInvitationsLockedReason = null;
31+
32+
if (me.data != null) {
33+
if (!me.data.features.can_invite_team) {
34+
lockedReason = "feature";
35+
} else if (me.data.role !== ROLE.owner && me.data.role !== ROLE.admin) {
36+
lockedReason = "role";
37+
}
38+
}
39+
40+
const canInvite = me.data != null && lockedReason === null;
2741

2842
const invitations = useInvitations(accountId);
2943
const inviteMutation = useInviteMember(accountId);
@@ -73,6 +87,7 @@ export function useInvitationsPage(): IInvitationsPageView {
7387

7488
return {
7589
canInvite,
90+
lockedReason,
7691
accountId,
7792
isLoading: me.isPending || invitations.isPending,
7893
invitations: invitations.data ?? [],

apps/ui/src/features/accounts/components/InvitationsPage/InvitationsPage.test.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe("InvitationsPage", () => {
110110
expect(screen.getByTestId("invite-submit")).toBeInTheDocument();
111111
});
112112

113-
it("hides the invite form when can_invite_team is false (feature gate)", () => {
113+
it("renders the upgrade prompt when can_invite_team is false (feature gate)", () => {
114114
renderPage(
115115
buildMe({
116116
features: {
@@ -122,12 +122,27 @@ describe("InvitationsPage", () => {
122122
);
123123

124124
expect(screen.queryByTestId("invite-submit")).toBeNull();
125+
expect(screen.getByTestId("invite-locked-feature")).toBeInTheDocument();
126+
expect(screen.getByRole("link", { name: /view plans/i })).toHaveAttribute(
127+
"href",
128+
"/account/billing"
129+
);
130+
expect(screen.queryByTestId("invite-locked-role")).toBeNull();
125131
});
126132

127-
it("hides the invite form for viewer role even when the feature is on", () => {
133+
it("renders the role-locked explainer for viewer role when the feature is on", () => {
128134
renderPage(buildMe({ role: "viewer" }));
129135

130136
expect(screen.queryByTestId("invite-submit")).toBeNull();
137+
expect(screen.getByTestId("invite-locked-role")).toBeInTheDocument();
138+
expect(screen.queryByTestId("invite-locked-feature")).toBeNull();
139+
});
140+
141+
it("renders the role-locked explainer for member role when the feature is on", () => {
142+
renderPage(buildMe({ role: "member" }));
143+
144+
expect(screen.queryByTestId("invite-submit")).toBeNull();
145+
expect(screen.getByTestId("invite-locked-role")).toBeInTheDocument();
131146
});
132147

133148
it("renders the empty state when there are no pending invitations", () => {

0 commit comments

Comments
 (0)