Skip to content

feat(ui): InvitationsPage names what's gating the invite form - #68

Merged
agjs merged 8 commits into
mainfrom
ui/invitations-upgrade-prompt
May 30, 2026
Merged

feat(ui): InvitationsPage names what's gating the invite form#68
agjs merged 8 commits into
mainfrom
ui/invitations-upgrade-prompt

Conversation

@agjs

@agjs agjs commented May 30, 2026

Copy link
Copy Markdown
Contributor

Why

The Team page (`/account/invitations`) used to wrap the invite form in ``, 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 header says "Invite teammates" but no input or button is anywhere.

This is the first instance of a broader UX principle: 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.

What

`useInvitationsPage` now exposes a discriminated `lockedReason`:

  • `"feature"` — the plan doesn't include team invites. Page renders an upgrade panel with a "View plans" CTA linking to `/account/billing`.
  • `"role"` — feature is on, but the current user is a viewer or member. Page renders an "ask an Owner or Admin" explainer.
  • `null` — the user can invite. Form renders as before.

The page is pure branching on this; tests assert on `invite-locked-feature` and `invite-locked-role` testids. English + German i18n strings added together.

Follow-ups

This is PR #A of three:

  • #B: extract a reusable `` component and audit every other silent-hide site across the UI.
  • #C: make the dev demo user able to actually exercise this in dev (Pro plan seed, or a dev override on plan-gated features).

Test plan

  • `bun run typecheck` (apps/ui): clean.
  • `bun run lint` on the InvitationsPage folder: clean.
  • `bun run test` (apps/ui): 16/16 pass, including 3 new locked-state assertions.
  • Visual: at `/account/invitations` with the dev demo user, the upgrade panel renders with a working "View plans" link to `/account/billing`.

agjs added 8 commits May 30, 2026 20:03
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.
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.
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.
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.
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.
…kflow

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.
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.
@agjs
agjs merged commit 011f77a into main May 30, 2026
26 checks passed
@agjs
agjs deleted the ui/invitations-upgrade-prompt branch May 30, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant