feat(ui): InvitationsPage names what's gating the invite form - #68
Merged
Conversation
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.
This reverts commit dd69cef.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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`:
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:
Test plan