Skip to content

Admin → Add-ons: grant the ai-agent licence per account; the licence is the chat's only switch - #1220

Merged
evanrbowers merged 13 commits into
feat/permitteer-loginfrom
feat/admin-addon-licenses
Sep 15, 2026
Merged

evanrbowers merged 13 commits into
feat/permitteer-loginfrom
feat/admin-addon-licenses

Conversation

@evanrbowers

Copy link
Copy Markdown
Contributor

What

The desktop half of the ai-agent licence (graphql-api docs/AI-AGENT-LICENSE.md; the API side has been live on dev and prod with nothing driving it):

  • Admin → Add-ons (/admin/add-ons/:productId?): a system-admin page that lists an add-on's holders and grants / revokes it by email, with an optional expiration. Generic over add-on products — ai-agent is the first; the next is a product row on the API and shows up in the selector. The product rides the URL (reload, deep link and the sidebar's remembered route all land on the same list); a disabled add-on still lists and revokes but hides Grant; a blank expiration is sent as null, not omitted (the API leaves an omitted one alone, and a re-grant from a blank form should give the open-ended grant the form shows).
  • The licence card says what it grants: LimitSetting learns ai-agent ("AI agent is available", and nothing at all when false — the alpha's decision 1), and the card wears the remote-ai mark.
  • The licence is the only switch. PENDING_FEATURES / CHAT_ALWAYS_ON / VITE_CHAT_ALWAYS_ON are retired (the 2026-08-31 note's client cleanup): a dev build and app.ai.remote.it no longer default the flag on, so an account without the add-on sees no header button, no docked column, no popout, and no AI Agent section on the Test page. Decision 3 of that note resolves as "the portal paywalls" — an unlicensed account on app.ai gets the ordinary app; the Amplify env's VITE_CHAT_ALWAYS_ON is inert.

Plan note: docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md.

Verified

  • npm run typecheck, npm test (electron 44/44, frontend 58/58 — models/adminAddonLicenses.test.ts covers the model), npm run i18n:check.
  • e2e test:dev against a local dev build of this branch (PORTAL_URL=http://localhost:3003): 80 passed, 1 skipped (the admin grant round trip — no admin key on this machine), 0 failed. Fresh e2e identities hold no grant, so the chat never docks for them now.
  • Driven by hand on dev: grant / revoke, the Add-on column, the licence card and line.

Consequences

  • A developer's dev account needs the grant (Admin → Add-ons) to see the chat locally.
  • app.ai.remote.it shows no chat to an account without the add-on, and says nothing about why (the popout is the one place that does). A notice keyed on the same gate is a follow-up if wanted.

🤖 Generated with Claude Code

evanrbowers and others added 5 commits September 14, 2026 17:15
The API's generic add-on admin surface (graphql-api docs/AI-AGENT-LICENSE.md — addonProducts,
addonCustomers, addAddonCustomer, removeAddonCustomer) has been live on dev and prod with nothing
in the app driving it. This is the desktop half: a system-admin page at /admin/add-ons/:productId
that lists an add-on's holders and grants or revokes it by email, with an optional expiration.

Generic over add-on products on purpose — ai-agent is the first, the next is a product row on the
API and appears in the page's selector. The product rides the URL so a reload, a deep link and the
sidebar's remembered route all land on the same list; a disabled add-on still lists and revokes
but hides Grant (the API refuses new grants for it); a blank expiration is sent as null, not
omitted, because the API leaves an omitted one alone and re-granting a time-boxed holder from a
blank form should give the open-ended grant the form shows.

With it, the licence card an account already got now says what it grants: LimitSetting learns
'ai-agent' ("AI agent is available" — and nothing at all when false, the alpha's decision 1), and
the card wears the remote-ai mark instead of the r3 brand mark.

Model tests cover the product switch, the same-product no-op, search trimming, paging and the
refused-request path. Plan note: docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list is filtered by the selector above it, but a row that reads "account · devices · members ·
granted" says nothing about WHAT was granted — the operator had to look up. An Add-on column
resolves the row's own productId to the product's description ("AI Agent"), and the ai-agent rows
wear the remote-ai mark instead of the generic puzzle piece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r AI-portal default

useChatEnabled already read limits['ai-agent'], but PENDING_FEATURES defaulted that flag ON for
every dev build (MODE === 'development') and for app.ai.remote.it (VITE_CHAT_ALWAYS_ON), so on
those the chat showed with or without a licence. The licence exists now — the ai-agent add-on,
granted per account from Admin → Add-ons — so the forward declaration is retired as the
2026-08-31 note planned: PENDING_FEATURES, CHAT_ALWAYS_ON and VITE_CHAT_ALWAYS_ON are gone, the
limits lookup is built only from what the API returns, and an account without the add-on has no
ai-agent entry at all — no header button, no docked column, no popout, and no AI Agent section on
the Test page (which also stops asking the agent service for the background status). The Test
page's Features list drops the "pending" row and its string: a feature no licence carries is
granted, not toggled on.

Decision 3 of that note resolves as "the portal paywalls": an unlicensed account on app.ai gets
the ordinary app with no chat; the Amplify env's VITE_CHAT_ALWAYS_ON is now inert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… primary, with a plus

The text button read as a label beside the selector. Same shape as the scripting header's Add.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-15T02:11:36.165705Z 3b18042 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8472eff91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


if (result !== 'ERROR' && result?.data?.data?.admin?.addonCustomers) {
const data = result.data.data.admin.addonCustomers
dispatch.adminAddonLicenses.setCustomers({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discard stale add-on customer responses

When an admin switches products while the previous product's request is still in flight, this unconditional update can let the older response overwrite the newly selected product's customers if it resolves last. The page then shows product A's rows under product B, and the revoke handler submits the current product B with a user ID selected from A; if that account also holds B, the admin can revoke the wrong grant. Associate each request with its product/search parameters and ignore the response when they no longer match the current state.

Useful? React with 👍 / 👎.

…pped (Codex round 1, P2)

Codex: switch products while the old product's page is in flight and, resolving last, it
overwrites the new product's rows — and a revoke from that list submits product B with a user id
picked from A's rows. The flagged line was fetch's write; the class is every await-then-write in
the model, so all of them are guarded by one mechanism rather than the one site:

  * latest-wins tickets. fetch and fetchMore share ONE counter — a refresh landing under a Load
    More would otherwise be appended to by rows paged off the list it replaced, which comparing
    the response's product/search to the store at resolve time (still matching) lets through. A
    superseded response writes nothing, the spinner included: the request that owns it clears it.
  * every retiring event takes the next ticket: a product switch and a new search term through
    the request they issue (setSearch is an effect now — commit + refetch — instead of a reducer
    the page watched with a skip-first-mount effect), sign-out explicitly (reset is an effect that
    retires the list AND the product list, then clears).
  * fetchProducts has its own ticket, retired only by sign-out: not selection-scoped, and two of
    its responses say the same thing.

Six new tests: the product switch, the refresh-under-Load-More, the spinner, sign-out; all fail
on the previous model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8547d9b5ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (!removeTarget || !productId) return

setRemoving(true)
const result = await graphQLRemoveAddonCustomer(productId, removeTarget.userId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revoke the selected row's product

When the confirmation remains open while the URL selection changes—for example through browser Back/Forward, or when a product refresh redirects away from a removed product—removeTarget still belongs to product A, but this call uses the current route's product B. If that account also holds B, confirming revokes B instead of the add-on whose row was selected. The latest-wins ticket fixes the earlier stale-list-response path, but it does not reset the dialog target; pass removeTarget.productId here or clear the target whenever the product changes.

Useful? React with 👍 / 👎.

…ve selection (Codex round 2, P2)

Codex: with the revoke confirm open, a URL move (Back/Forward, or the product refresh redirecting
off a product the API dropped) leaves removeTarget on product A while the mutation is built from
the route's product B — an account holding both loses the wrong add-on. Round 1's tickets guard
the LIST; this is the same class one level up, and the grant dialog had it too: opened under the
title "Grant A", it would have granted B.

Closed for both: the revoke uses the row's own productId (the licence revoked is the one the row
showed, by construction), the grant dialog captures the product it was opened for and titles,
mutates and reports with that, and a change of selection closes whichever dialog is up — what it
was about is no longer on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c4291937e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Blank = open-ended. Sent as null, not omitted: the API leaves an OMITTED expiration alone,
// and re-granting a time-boxed holder from a blank form should give the open-ended grant the
// form shows, not silently keep the old date.
const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Describe time-boxed add-ons as expiring

When an admin supplies this optional expiration, the resulting add-on appears on the account's license card, but LicensingSetting.tsx:32-34 labels every non-null license expiration as “Renews.” These manually granted add-ons instead stop providing the feature at that timestamp, so affected users are falsely told that access will renew; render an expiration-specific label for add-on licenses.

Useful? React with 👍 / 👎.

Comment on lines +100 to +102
if (ticket !== productsRequest || result === 'ERROR') return
const products: AdminAddonProduct[] = result?.data?.data?.admin?.addonProducts || []
dispatch.adminAddonLicenses.setProducts(products)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve products when no GraphQL response is returned

When the page fetches products while offline or before an auth header is available, post() returns undefined, but this path converts that missing response into a successfully loaded empty product list. The page then incorrectly says that no add-ons are defined and discards any previously loaded products; reconnecting does not repair it because CloudSync.all() does not fetch this admin model. Only mark products loaded when addonProducts is actually present, while retaining the prior state on a missing response.

Useful? React with 👍 / 👎.

…issing product response writes nothing (Codex round 3, two P2s)

1. LicensingSetting labelled EVERY licence expiration "Renews" — billing's word, right for a
   subscribed remote.it licence and wrong for a time-boxed add-on grant, which ends at that date
   (and for a custom or reseller term, which never renewed either). "Renews" now needs a
   subscription on the licence — the API attaches one only to a remote.it licence billing owns;
   any other expiration reads "Expires". New key licensingSetting.expires.

2. fetchProducts read a MISSING response (offline, or before an auth header exists — post()
   returns undefined, not 'ERROR') as an empty product list: productsLoaded flipped, the products
   already held were discarded, and the page announced no add-ons are defined. Only an answer
   carrying addonProducts writes now; fetch and fetchMore already kept their rows on a missing
   response, so this closes the last unguarded writer. Test: undefined and an answer without the
   field both leave the products alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bcc2eb3800

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

</Box>
) : loading && customers.length === 0 ? (
<LoadingMessage message={`Loading ${label} licenses...`} />
) : customers.length === 0 ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for products before showing an empty license list

When first entering this page, productsLoaded and loading are both false while customers is empty, so this branch reports “No add-on licenses granted” before the product request resolves. If that request returns no response, the new guard in fetchProducts deliberately leaves the same unresolved state, and the reconnect path in CloudSync.all() does not refetch this admin model, so the false empty result persists until a manual refresh. Fresh evidence after the earlier cache-preservation fix is that this fallback still treats an unresolved product list as an empty customer result; show a loading/retry state until products and a selection are resolved.

Useful? React with 👍 / 👎.

} else if (adminAddonLicensesPage) {
title = 'Refresh add-on licenses'
methods.push(async () => await dispatch.adminAddonLicenses.fetchProducts())
methods.push(async () => await dispatch.adminAddonLicenses.fetch())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the selected product before refreshing customers

When Refresh is pressed after the API has stopped listing the selected product, CloudSync.call() runs these methods sequentially, but fetchProducts() only replaces the product array; the model's productId remains the removed ID until the page's redirect effect runs after rendering. This second method therefore starts an addonCustomers request for the removed product, surfacing a GraphQL error before the redirect selects and fetches the fallback product. Clear or validate the selection after refreshing products, or let the resulting selection change own the customer fetch.

Useful? React with 👍 / 👎.

and its request's ticket retires whatever the old product still had in flight. Re-selecting
the current one is a no-op (the URL effect fires on every render of the route). */
async select(productId: string, rootState) {
if (rootState.adminAddonLicenses.productId === productId) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refetch the selected product after changing API targets

When a system admin views this list, changes the API target in Test Settings, and returns to the same product URL, the add-on model still contains the previous stage's productId and customers because cloudSync.all() does not reset or fetch this model. The page refreshes only the product catalog on mount, and shared product IDs such as ai-agent make this equality return early, so customers from the old stage remain displayed while subsequent grant or revoke mutations are sent to the new stage. The fresh stale-data path not covered by the latest-wins tickets is this API-target change; key the cache by target, reset it during target switches, or force a customer fetch when the page remounts.

Useful? React with 👍 / 👎.

… (Codex round 4, three P2s)

Round 4's three findings share a root: the model had no notion of its own lifecycle. "Products not
loaded yet", "selection unresolved", "list never asked", "list from another API target" all
collapsed into customers.length === 0, and refresh was two independent calls. Restructured rather
than patched at the three sites:

  * load STATUSES. products and the list each carry idle | loading | loaded | failed, kept apart
    from what they last delivered; a failed ask keeps what was held. The page decides every screen
    from a status AND the rows — loading until the catalogue answers, "Couldn't load … / Retry"
    when it did not, "No add-on products" only once it said so, loading again until a product is
    chosen and its list has answered, and "No … granted" only from a loaded, empty answer. A
    failed Load More keeps the rows and the button.
  * refresh(urlProduct) is the ONE way in — on mount, on every move of the URL's product, and the
    refresh button's single call (with the route's product): catalogue first; the selection
    checked against the fresh catalogue (the URL's product if listed, else the one held if still
    listed, else cleared — so a product the API dropped never gets a list request, and the page
    redirects to one that exists); then the list, fetched AFRESH every time. Afresh is the point
    for the third finding: Test Settings switches the API target without a reload and
    cloudSync.all() knows nothing of this model, so a remount used to sit over the old stage's
    rows behind a product id that is the same on every stage. `select` is gone — the URL effect
    was its only caller and refresh subsumes it.

Tests (20): the status reducers; fetchProducts resolving to the catalogue, and marking failed on
ERROR / no response / no field; refresh taking the URL's product, keeping the held one, clearing a
vanished one, and doing nothing when the catalogue did not answer; the ticket races as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

…heck)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 54b526897a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +85 to +86
productId === state.productId
? state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear cached customers before a cross-target refresh

When a system admin changes the API target in Test Settings and returns to the same product ID, this equality keeps the previous target's customers visible and interactive while refresh() fetches the new target; they remain indefinitely if the customer request fails because a nonempty failed list still renders the grid. A revoke during that interval is sent to the new API target using an old-target user ID, potentially revoking the corresponding account's grant in the wrong environment. Fresh evidence after the earlier forced-refetch fix is that the refetch does not clear or hide the cache while it is pending; invalidate the cached list when the target changes or keep it noninteractive until the new list succeeds.

Useful? React with 👍 / 👎.

</Quote>
</ListItem>
</List>
{chatEnabled && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revoke background access when the license turns off

When a user has enabled AI background work and then disables ai-agent in the Features list, or the add-on is revoked or expires, chatEnabled becomes false and this removes the only UI that calls backgroundDisable(). The stored grant is not tied to this component's lifecycle—chat.signOut() explicitly invokes backgroundDisable() because background access otherwise survives—so losing the entitlement can leave background AI access active with no way to revoke it except re-enabling the feature or signing out. Revoke the background grant when the entitlement transitions off, or keep its disable control reachable.

Useful? React with 👍 / 👎.

… includes the API target (Codex round 5, P1 + P2)

P1 — a regression of the licence gate. Hiding the Test page's AI Agent section for an unlicensed
account also hid the only control that revokes the agent's BACKGROUND grant, which is the agent's
own standing at the AS and survives the entitlement (chat.signOut revokes it explicitly for that
reason). An account whose add-on was revoked, expired, or switched off in Features kept background
access with no way to end it short of signing out. Revoking when the entitlement flips off would
be wrong — it also flips when merely viewing an organization without the add-on — so instead the
status is always asked (one GET, on this staff page only) and the section shows while the feature
is licensed OR a grant is standing, with the row saying the account no longer has Remote.It AI and
to switch it off. Unlicensed with no grant: nothing, as before.

P2 — rows fetched under one API target sat on screen, interactive, behind the same product id
while refresh fetched from another (Test Settings switches the target without a reload), and
stayed if that fetch failed. The target is now part of the list's identity alongside the product:
refresh stamps the current target FIRST, before anything is awaited, and a changed target empties
the list exactly as a changed product does — rows from elsewhere leave at once, and a failed fetch
shows "Couldn't load … / Retry", never them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac4b7e2a7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

staying interactive until (or beyond, if it fails) the new answer. A switch's request retires
whatever the old product still had in flight (the tickets above). */
async refresh(preferredProductId: string | undefined, rootState) {
dispatch.adminAddonLicenses.setTarget(getApiURL())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retire old-target list requests before clearing rows

When a customer-list request from API target A is still in flight as the admin switches to target B and returns to this page, setTarget() clears the cache but does not advance listRequest. While this refresh awaits B's product catalogue, A's response can still pass the ticket check and repopulate the grid; because product IDs are shared across stages, revoking one of those rows sends the mutation to B with an A-derived user ID. Fresh evidence after the earlier cross-target cache fix is that an in-flight response can now repopulate the cleared cache, so invalidate the list ticket as soon as the target changes.

Useful? React with 👍 / 👎.

Comment thread frontend/src/pages/TestPage.tsx Outdated
useEffect(() => {
backgroundStatus().then(setBackgroundEnrolled)
}, [])
const showAgentSettings = chatEnabled || !!backgroundEnrolled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the revoke control visible when status lookup fails

When the enrollment GET fails because the agent is offline or returns a 401/5xx, backgroundStatus() converts that failure to false, and this expression hides the entire section for an unlicensed user. If that user still has the standing background grant described above, the only control that can revoke it disappears and is not retried while the page remains mounted. Fresh evidence after the earlier entitlement-loss fix is that request failure is still indistinguishable from a confirmed unenrolled response; preserve an unknown/error state and keep the disable control reachable until absence is confirmed.

Useful? React with 👍 / 👎.

…ge retires the page in flight (Codex round 6)

P1 (hardening, resolved at the root instead): round 5 made the AI Agent section show for an
UNLICENSED account while a background grant stands, on the premise that its toggle was the only
control that could end the grant; round 6 then asked for that status probe to be hardened against
failure. The premise was wrong. The background grant is an OAuth grant held at the AS for the
agent's own client, and Account → Connected Apps — the same test-UI audience, not gated on the
licence — lists and revokes it, killing every token minted from it, whether or not the agent
service answers. (The agent gates on nothing licence-shaped today, and no background scheduler
exists yet; whether it should refuse background work for a lapsed licence is its question.) So the
section is back behind the plain licence gate, the orphaned-grant row and string are gone, and the
comment points at the door that was always there.

P2 (a real gap in the class): a target change emptied the list but never took a ticket, so a page
still in flight from the OTHER target could pass the check and refill the emptied list while the
catalogue was awaited — product ids being the same on every stage. refresh now retires the list's
ticket whenever the target differs, before anything is awaited. Pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f4c518789

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Blank = open-ended. Sent as null, not omitted: the API leaves an OMITTED expiration alone,
// and re-granting a time-boxed holder from a blank form should give the open-ended grant the
// form shows, not silently keep the old date.
const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-evaluate the entitlement when a timed grant expires

When a time-boxed grant expires while the grantee keeps the app continuously online and focused, the cached ai-agent limit remains true: useChatEnabled() reads only that cached boolean, and no timer refreshes or invalidates it when this timestamp passes. Consequently the chat stays mounted and usable until an unrelated manual sync or reconnect, so the newly supported expiration does not actually end client access at its declared time. Schedule an entitlement refresh/unmount for the expiration or include expiration validity in the gate.

Useful? React with 👍 / 👎.

>
{/* Each screen is decided by a STATUS and the rows, never by an empty array alone — "nothing
has answered yet", "the answer was no", and "nobody holds it" are different screens. */}
{!products.length && productsStatus === 'failed' ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface product failures when a cached catalog exists

When an admin changes API targets and the new target's product request fails, setTarget() clears the customer rows but fetchProducts() deliberately retains the old target's nonempty product catalog and marks it failed. This condition ignores that failure because products.length is still nonzero, then the page reaches the idle-list branch and displays “Loading … licenses” indefinitely instead of the error and Retry action. Handle productsStatus === 'failed' when no usable list exists even if a stale catalog is cached.

Useful? React with 👍 / 👎.

…ngth (Codex round 7, P2)

After a target switch the rows are emptied first and the catalogue refetched; when that fetch
failed, fetchProducts kept the stale catalogue (by design) — and the screen chooser, keyed on
products.length, took a non-empty catalogue for a healthy one, fell through to "Loading … licenses"
and stayed there with no Retry. The chooser now asks one question first — is anything on screen
USABLE (the list answered, or rows are held) — and, when nothing is, a failed catalogue is the
screen regardless of what stale catalogue is held; with something usable the rows stay and the
failure is the snackbar. One Retry element serves both failure screens.

Round 7's P1 — a time-boxed grant lapsing while the grantee's app stays open keeps its cached limit
until the next sync — is how every licensed feature has always behaved, and the enforcement point
is the server (the agent gates on nothing licence-shaped); recorded as the follow-up it is in the
plan note rather than papered over with a client timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanrbowers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 3b180429ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@evanrbowers
evanrbowers merged commit 512060d into feat/permitteer-login Sep 15, 2026
3 checks passed
@evanrbowers
evanrbowers deleted the feat/admin-addon-licenses branch September 15, 2026 02:30
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