From 24ada95ef366d930dbb5177eccdd060da63c9a60 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 17:15:29 -0700 Subject: [PATCH 01/13] =?UTF-8?q?Admin=20=E2=86=92=20Add-ons:=20grant=20an?= =?UTF-8?q?d=20revoke=20the=20ai-agent=20licence=20per=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-09-14-admin-addon-licenses-page.md | 114 +++++ .../buttons/RefreshButton/RefreshButton.tsx | 7 + frontend/src/components/AdminSidebarNav.tsx | 11 + frontend/src/components/Header/Header.tsx | 4 +- frontend/src/components/LicensingIcon.tsx | 7 +- frontend/src/components/LimitSetting.tsx | 8 + frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + .../src/models/adminAddonLicenses.test.ts | 125 ++++++ frontend/src/models/adminAddonLicenses.ts | 147 +++++++ frontend/src/models/auth.ts | 1 + frontend/src/models/index.ts | 3 + frontend/src/models/plans.ts | 3 + .../AdminAddonLicensesListPage.tsx | 403 ++++++++++++++++++ frontend/src/routers/Router.tsx | 4 + frontend/src/services/graphQLMutation.ts | 31 ++ frontend/src/services/graphQLRequest.ts | 46 ++ 19 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md create mode 100644 frontend/src/models/adminAddonLicenses.test.ts create mode 100644 frontend/src/models/adminAddonLicenses.ts create mode 100644 frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx diff --git a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md new file mode 100644 index 000000000..a8680b700 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -0,0 +1,114 @@ +# Admin → Add-ons: granting the `ai-agent` licence per account + +**Goal:** a system-admin page on the desktop that lists, grants and revokes **add-on licences** — +the per-account entitlement that turns the Remote.It AI chat on. `ai-agent` is the first add-on; +the page is generic over add-on products so the next one is a data change on the API, not a page. + +**Status (2026-09-14):** built on `feat/admin-addon-licenses` (branched from +`feat/permitteer-login`). The API side shipped earlier and is live on dev and prod — see +graphql-api `docs/AI-AGENT-LICENSE.md` for the model and every decision behind it. This note is the +desktop half: what the page does, where it lives, and how to verify it. + +--- + +## Where things stood before this branch + +- **The gate already existed.** `useChatEnabled()` reads `limits['ai-agent']` through + `selectLimitsLookup` (`frontend/src/hooks/useChatEnabled.ts`), the same selector that gates + `saml`, `roles` and `tagging`. `PENDING_FEATURES` (`frontend/src/constants.ts`) still defaults the + flag ON for local dev builds and app.ai.remote.it, and the API's value wins the moment it arrives — + which it now does for any account holding the add-on licence. +- **The API was done.** graphql-api `main` carries the generic add-on admin surface — + `admin.addonProducts`, `admin.addonCustomers(product, from, size, search)`, + `addAddonCustomer(product, email, expiration?)`, `removeAddonCustomer(product, userId)` — with + `AddonCustomer` shaped like `EnterpriseCustomer` plus `productId` and `expiration`. The `ai-agent` + product (`96aa515b-cf6b-40bf-8d04-7972cbbc7c39`) with its one `ALPHA` plan carrying the `ai-agent` + limit is in the shared database. e2e `addon-license.spec.ts` proves the grant → limit → revoke + round trip on every lane. +- **The desktop already rendered the licence** — `LicensingSetting` draws one card per licence, so a + granted account showed an "AI Agent Alpha plan" card — but with no feature line under it + (`LimitSetting` renders nothing for a limit name it does not know) and the r3 brand mark for an + icon. And there was no way to grant one from the app. + +## What this branch adds + +### The page: `/admin/add-ons/:productId?` + +`frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx`, a clone of the +enterprise-licences page (`AdminEnterpriseLicensesListPage.tsx`) with the product made explicit: + +- **The product is in the URL.** `/admin/add-ons` alone redirects to the product last looked at + (remembered through `ui.defaultSelection['admin']`, the same slot the sidebar's other entries use) + or else the first add-on the API lists; a link to a product the API no longer lists is bounced the + same way, and a deep link to a real one is honoured. The Header treats every `/admin/add-ons/*` + path as a root page (no Back arrow) — the product is the list, not a detail. +- **Header row:** an **Add-on** selector (always shown — one entry today), **Grant Add-on**, and the + email/name search (committed on Enter, like the other admin lists). +- **Columns:** Account, Devices, Members, Granted, **Expires** (`-` when open-ended; a past date + reads "Expired " in red — the API keeps the row but skips it in the limits merge until it is + revoked), and a trash action. +- **Grant dialog:** account email plus an optional **Expires** (`datetime-local`, `min` = now — the + API refuses a date in the past). Blank is 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. Granting an account that already + holds the add-on is idempotent on the API's side and replaces its expiration. +- **Revoke:** a confirm naming the add-on and the account; the account loses the feature at once + (the API publishes `LicenseUpdatedEvent`, which the desktop already turns into `plans.updated`). +- **A disabled add-on** (`Product.enabled = false`, the alpha's kill switch) still lists in the + selector, marked "(disabled)", and its grants can still be revoked — but Grant is hidden, since the + API refuses new grants for it. +- **Errors:** `graphQLBasicRequest` already shows the API's own message as a snackbar ("User does not + exist: …", "Add-on is disabled", the Stripe guard). The page does not overwrite it with a generic + "Failed…" the way the enterprise page does; the grant dialog stays open for a correction. + +### Wiring + +- `models/adminAddonLicenses.ts` — products + the selected product + the paginated holder list + (`fetchProducts`, `select`, `fetch`, `fetchMore`, `reset`); registered in `models/index.ts`, reset + on sign-out in `models/auth.ts`, refreshed by the header's refresh button (`RefreshButton.tsx`). +- `services/graphQLRequest.ts` — `graphQLAdminAddonProducts`, `graphQLAdminAddonCustomers`; + `services/graphQLMutation.ts` — `graphQLAddAddonCustomer`, `graphQLRemoveAddonCustomer`. +- `routers/Router.tsx` (the `/admin/*` block), `components/AdminSidebarNav.tsx` ("Add-ons"), + `components/Header/Header.tsx` (root-page rule). + +### The licence card + +- `components/LimitSetting.tsx` — `case 'ai-agent'`: "AI agent is available" when true, and **no + row at all** when false (the alpha's decision 1: accounts that lack it are shown nothing; the API + sends no default row, so today the false branch never arrives anyway). Key + `limitSetting.aiAgentAvailable`, extracted into all four catalogs. +- `models/plans.ts` — `AI_AGENT_PRODUCT_ID`; `components/LicensingIcon.tsx` draws the `remote-ai` + mark for that product's card. + +## Verifying + +- `npm run typecheck`, `cd frontend && npm test` (`models/adminAddonLicenses.test.ts` covers the + model: product switch empties the list, same-product select is a no-op, search is trimmed into the + request, paging appends from the rows held, a refused request clears the spinner), `npm run + i18n:check`. +- Driving it: run the frontend against dev (`frontend/.env.local`), sign in as a **system admin** + (`r3_Users.admin`), Admin → Add-ons. Grant a test account with and without an expiration; on that + account, Account → Licensing shows the "AI Agent Alpha plan" card with "AI agent is available", + and on a non-dev build the header's AI button appears (a dev build defaults the flag on — flip + the Test page override to see the licence's own value). Revoke → the card, the line and the + button go, live. Grant an unknown email → the API's message, dialog still open. +- The API round trip is covered by e2e `addon-license.spec.ts`; a UI spec would need an admin + sign-in through Permitteer, which the suite does not have — deliberately not added. + +## Rollout + +PR into `feat/permitteer-login` → Codex loop → merge → app.dev auto-builds and `next` mirrors. No +server, database or Amplify-env change: the API and rows are already live on every stage, so the +page works the day it lands, and prod gets it with the branch's promotion. + +## Left for later + +- **`PENDING_FEATURES` cleanup** (2026-08-31 note, "Client cleanup"): the limit is real now, but + with no default row an account WITHOUT the add-on still gets no `ai-agent` limit at all, so the + forward-declared default is what keeps local dev and app.ai on. Retire it when the API sends a + default row (the GA upsell line) or app.ai gets its own floor — decision 3 in that note. +- **The admin user-detail "License" column** (`pages/AdminUsersPage/adminUserAttributes.tsx`, a + TODO) is the natural place to *show* an account's add-ons beside its plan. +- **Phase 2/3** (paid tiers carrying the limit; the add-on sold through Stripe) are API-side — see + graphql-api `docs/AI-AGENT-LICENSE.md`. Nothing on this page changes for them: a Stripe-owned + licence is refused by the API's `remove`, and the page just shows that message. diff --git a/frontend/src/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index 61edaaeb7..d8ca96286 100644 --- a/frontend/src/buttons/RefreshButton/RefreshButton.tsx +++ b/frontend/src/buttons/RefreshButton/RefreshButton.tsx @@ -39,6 +39,7 @@ export const RefreshButton: React.FC = props => { const adminUsersPage = useRouteMatch('/admin/users') const adminPartnersPage = useRouteMatch('/admin/partners') const adminEnterpriseLicensesPage = useRouteMatch('/admin/enterprise-licenses') + const adminAddonLicensesPage = useRouteMatch('/admin/add-ons') const adminNoticesPage = useRouteMatch('/admin/notices') const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs']) const runsPage = useRouteMatch<{ fileID?: string }>('/runs/:fileID?') @@ -140,6 +141,12 @@ export const RefreshButton: React.FC = props => { title = 'Refresh enterprise customers' methods.push(async () => await dispatch.adminEnterpriseLicenses.fetch()) + // admin add-on licenses page + } else if (adminAddonLicensesPage) { + title = 'Refresh add-on licenses' + methods.push(async () => await dispatch.adminAddonLicenses.fetchProducts()) + methods.push(async () => await dispatch.adminAddonLicenses.fetch()) + // admin notices pages } else if (adminNoticesPage) { title = 'Refresh notices' diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index 2efe4554b..ff400be87 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -99,6 +99,17 @@ export const AdminSidebarNav: React.FC = () => { + handleNavClick('/admin/add-ons')} + > + + + + + + = ({ panels = 1 }) => { '/admin/notices', '/partner-stats', ] - const isAdminRootPage = adminRootPages.includes(location.pathname) + // The add-ons page keys its product into the URL (/admin/add-ons/:productId): that is its root + // list, not a detail with a level above it. + const isAdminRootPage = adminRootPages.includes(location.pathname) || location.pathname.startsWith('/admin/add-ons') const isRootMenu = menu === location.pathname || isAdminRootPage return ( diff --git a/frontend/src/components/LicensingIcon.tsx b/frontend/src/components/LicensingIcon.tsx index 10752ca17..1d048d0e5 100644 --- a/frontend/src/components/LicensingIcon.tsx +++ b/frontend/src/components/LicensingIcon.tsx @@ -1,11 +1,16 @@ import React from 'react' -import { REMOTEIT_PRODUCT_ID, AWS_PRODUCT_ID } from '../models/plans' +import { REMOTEIT_PRODUCT_ID, AWS_PRODUCT_ID, AI_AGENT_PRODUCT_ID } from '../models/plans' import { Icon } from './Icon' export const LicensingIcon: React.FC<{ license: ILicense }> = ({ license }) => { let type: IconType = 'brands' let name: string = '' + // The add-on's card gets the feature's own mark rather than the remote.it brand mark. Keyed on + // the product, unlike the switch below, which compares a licence id to product ids and so only + // ever lands on its default. + if (license.plan.product.id === AI_AGENT_PRODUCT_ID) return + switch (license.id) { case AWS_PRODUCT_ID: name = 'aws' diff --git a/frontend/src/components/LimitSetting.tsx b/frontend/src/components/LimitSetting.tsx index bedc6d646..e9a728038 100644 --- a/frontend/src/components/LimitSetting.tsx +++ b/frontend/src/components/LimitSetting.tsx @@ -38,6 +38,14 @@ export const LimitSetting: React.FC<{ limit: ILimit }> = ({ limit }) => { ? t('limitSetting.rolesAvailable', 'Custom roles are available') : t('limitSetting.rolesUnavailable', 'Custom roles are unavailable') break + case 'ai-agent': + // An alpha granted per account (graphql-api docs/AI-AGENT-LICENSE.md, decision 1): accounts + // that lack it are shown nothing, so there is no "unavailable" line — false renders no row. + if (limit.value) { + template = 'text' + message = t('limitSetting.aiAgentAvailable', 'AI agent is available') + } + break case 'tagging': // ignore break diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 7f41cd0f8..6010f3434 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1113,6 +1113,7 @@ "renews": "Verlängert sich" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "E-Mail-Support verfügbar", "evaluationPeriod": "Diensten wird ein Testzeitraum von {{period}} gewährt", "forumSupport": "Nur Forum-Support", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index b4fde2029..8208d4250 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1113,6 +1113,7 @@ "renews": "Renews" }, "limitSetting": { + "aiAgentAvailable": "AI agent is available", "emailSupport": "Email support available", "evaluationPeriod": "Services are granted an evaluation period of {{period}}", "forumSupport": "Forum support only", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 2ef27e54b..9050e6ac2 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1124,6 +1124,7 @@ "renews": "Se renueva" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "Soporte por correo electrónico disponible", "evaluationPeriod": "Los servicios reciben un período de evaluación de {{period}}", "forumSupport": "Solo soporte en el foro", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 7bd87ab9d..d611742fb 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1102,6 +1102,7 @@ "renews": "更新" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "メールサポートが利用可能です", "evaluationPeriod": "サービスには{{period}}の評価期間が付与されます", "forumSupport": "フォーラムサポートのみ", diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts new file mode 100644 index 000000000..a9e4a3100 --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// The model touches nothing but the two request wrappers; stub those and drive the effects and +// reducers directly, the way chat.test.ts does. +const { graphQLAdminAddonProducts, graphQLAdminAddonCustomers } = vi.hoisted(() => ({ + graphQLAdminAddonProducts: vi.fn(), + graphQLAdminAddonCustomers: vi.fn(), +})) +vi.mock('../services/graphQLRequest', () => ({ graphQLAdminAddonProducts, graphQLAdminAddonCustomers })) + +import { adminAddonLicenses } from './adminAddonLicenses' + +const model = adminAddonLicenses as any +const effectsFor = (dispatch: any) => model.effects(dispatch) +const makeDispatch = () => ({ + adminAddonLicenses: { + setProducts: vi.fn(), + setProductId: vi.fn(), + setCustomers: vi.fn(), + appendCustomers: vi.fn(), + setLoading: vi.fn(), + fetch: vi.fn(), + }, +}) +const stateWith = (over: Record = {}) => ({ + adminAddonLicenses: { ...model.state, ...over }, +}) +const page = (items: unknown[], total: number, hasMore: boolean) => ({ + data: { data: { admin: { addonCustomers: { items, total, hasMore } } } }, +}) +const holder = (userId: string) => ({ userId, email: `${userId}@example.com` }) + +beforeEach(() => { + graphQLAdminAddonProducts.mockReset() + graphQLAdminAddonCustomers.mockReset() +}) + +describe('adminAddonLicenses reducers', () => { + it('a new product empties the list — the rows on screen belong to the old one', () => { + const before = { ...model.state, productId: 'a', customers: [holder('u1')], total: 1, hasMore: true } + const after = model.reducers.setProductId(before, 'b') + expect(after).toMatchObject({ productId: 'b', customers: [], total: 0, hasMore: false }) + }) + + it('re-selecting the current product keeps the state, identity included', () => { + const before = { ...model.state, productId: 'a', customers: [holder('u1')], total: 1 } + expect(model.reducers.setProductId(before, 'a')).toBe(before) + }) + + it('the product list marks itself loaded, empty or not', () => { + expect(model.reducers.setProducts(model.state, [])).toMatchObject({ products: [], productsLoaded: true }) + }) +}) + +describe('adminAddonLicenses effects', () => { + it('fetchProducts stores what the API lists and ignores a refused request', async () => { + const dispatch = makeDispatch() + const products = [{ id: 'p1', name: 'ai-agent', description: 'AI Agent', enabled: true }] + graphQLAdminAddonProducts.mockResolvedValueOnce({ data: { data: { admin: { addonProducts: products } } } }) + await effectsFor(dispatch).fetchProducts() + expect(dispatch.adminAddonLicenses.setProducts).toHaveBeenCalledWith(products) + + graphQLAdminAddonProducts.mockResolvedValueOnce('ERROR') + await effectsFor(dispatch).fetchProducts() + expect(dispatch.adminAddonLicenses.setProducts).toHaveBeenCalledTimes(1) + }) + + it('select switches product and fetches; the product already on screen is a no-op', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).select('p2', stateWith({ productId: 'p1' })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith('p2') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + + await effectsFor(dispatch).select('p1', stateWith({ productId: 'p1' })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledTimes(1) + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + }) + + it('fetch asks for the selected product with the committed search, and never without a product', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: undefined })) + expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([holder('u1')], 7, true)) + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: 'p1', pageSize: 50, searchValue: ' ann ' })) + expect(graphQLAdminAddonCustomers).toHaveBeenCalledWith('p1', { from: 0, size: 50 }, 'ann') + expect(dispatch.adminAddonLicenses.setLoading).toHaveBeenCalledWith(true) + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledWith({ + customers: [holder('u1')], + total: 7, + hasMore: true, + }) + }) + + it('a refused list clears the spinner and leaves the rows alone', async () => { + const dispatch = makeDispatch() + graphQLAdminAddonCustomers.mockResolvedValueOnce('ERROR') + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: 'p1' })) + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + expect(dispatch.adminAddonLicenses.setLoading).toHaveBeenLastCalledWith(false) + }) + + it('fetchMore pages from the rows already held and appends', async () => { + const dispatch = makeDispatch() + const held = [holder('u1'), holder('u2')] + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([holder('u3')], 3, false)) + await effectsFor(dispatch).fetchMore( + undefined, + stateWith({ productId: 'p1', customers: held, hasMore: true, pageSize: 2 }) + ) + expect(graphQLAdminAddonCustomers).toHaveBeenCalledWith('p1', { from: 2, size: 2 }, undefined) + expect(dispatch.adminAddonLicenses.appendCustomers).toHaveBeenCalledWith({ + customers: [holder('u3')], + total: 3, + hasMore: false, + }) + }) + + it('fetchMore does nothing at the end of the list or while a page is loading', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).fetchMore(undefined, stateWith({ productId: 'p1', hasMore: false })) + await effectsFor(dispatch).fetchMore(undefined, stateWith({ productId: 'p1', hasMore: true, loading: true })) + expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts new file mode 100644 index 000000000..1790f209b --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.ts @@ -0,0 +1,147 @@ +import { createModel } from '@rematch/core' +import { graphQLAdminAddonCustomers, graphQLAdminAddonProducts } from '../services/graphQLRequest' +import type { RootModel } from '.' + +/* Admin grants of ADD-ON licences (graphql-api docs/AI-AGENT-LICENSE.md) — generic over add-on + products, of which ai-agent is the first. An add-on product has one entitled plan and no default + plan: holding the licence row IS the entitlement, so the list is every holder and the two + actions are grant / revoke. Same shape as adminEnterpriseLicenses plus the product the page is + looking at; a future add-on is a product row on the API and nothing here. */ + +export interface AdminAddonProduct { + id: string + name: string + description?: string | null + /* The kill switch: a disabled add-on still lists (and lets an admin revoke) its residual + grants, but the API refuses new ones. */ + enabled: boolean +} + +export interface AdminAddonCustomer { + productId: string + userId: string + email: string + name: string + deviceCount: number + memberCount: number + licenseId: string + created: string + /* Set when the grant is time-boxed; the licence stops counting the moment it passes. */ + expiration?: string | null +} + +interface AdminAddonLicensesState { + products: AdminAddonProduct[] + productsLoaded: boolean + productId?: string + customers: AdminAddonCustomer[] + total: number + hasMore: boolean + loading: boolean + pageSize: number + searchValue: string +} + +const initialState: AdminAddonLicensesState = { + products: [], + productsLoaded: false, + productId: undefined, + customers: [], + total: 0, + hasMore: false, + loading: false, + pageSize: 50, + searchValue: '', +} + +type Page = { customers: AdminAddonCustomer[]; total: number; hasMore: boolean } + +export const adminAddonLicenses = createModel()({ + name: 'adminAddonLicenses', + state: initialState, + reducers: { + setProducts: (state, products: AdminAddonProduct[]) => ({ ...state, products, productsLoaded: true }), + // Switching product empties the list: the rows on screen belong to the old one. + setProductId: (state, productId?: string) => + productId === state.productId ? state : { ...state, productId, customers: [], total: 0, hasMore: false }, + setCustomers: (state, payload: Page) => ({ + ...state, + customers: payload.customers, + total: payload.total, + hasMore: payload.hasMore, + loading: false, + }), + appendCustomers: (state, payload: Page) => ({ + ...state, + customers: [...state.customers, ...payload.customers], + total: payload.total, + hasMore: payload.hasMore, + loading: false, + }), + setLoading: (state, loading: boolean) => ({ ...state, loading }), + setSearch: (state, searchValue: string) => ({ ...state, searchValue }), + reset: () => initialState, + }, + effects: dispatch => ({ + async fetchProducts() { + const result = await graphQLAdminAddonProducts() + if (result === 'ERROR') return + const products: AdminAddonProduct[] = result?.data?.data?.admin?.addonProducts || [] + dispatch.adminAddonLicenses.setProducts(products) + }, + + /* The page's selection. The list belongs to one product, so a new product fetches afresh; + 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 + dispatch.adminAddonLicenses.setProductId(productId) + await dispatch.adminAddonLicenses.fetch() + }, + + async fetch(_: void, rootState) { + const state = rootState.adminAddonLicenses + if (!state.productId) return + dispatch.adminAddonLicenses.setLoading(true) + + const result = await graphQLAdminAddonCustomers( + state.productId, + { from: 0, size: state.pageSize }, + state.searchValue.trim() || undefined + ) + + if (result !== 'ERROR' && result?.data?.data?.admin?.addonCustomers) { + const data = result.data.data.admin.addonCustomers + dispatch.adminAddonLicenses.setCustomers({ + customers: data.items || [], + total: data.total || 0, + hasMore: !!data.hasMore, + }) + } else { + dispatch.adminAddonLicenses.setLoading(false) + } + }, + + async fetchMore(_: void, rootState) { + const state = rootState.adminAddonLicenses + if (!state.productId || !state.hasMore || state.loading) return + dispatch.adminAddonLicenses.setLoading(true) + + const result = await graphQLAdminAddonCustomers( + state.productId, + { from: state.customers.length, size: state.pageSize }, + state.searchValue.trim() || undefined + ) + + if (result !== 'ERROR' && result?.data?.data?.admin?.addonCustomers) { + const data = result.data.data.admin.addonCustomers + dispatch.adminAddonLicenses.appendCustomers({ + customers: data.items || [], + total: data.total || 0, + hasMore: !!data.hasMore, + }) + } else { + dispatch.adminAddonLicenses.setLoading(false) + } + }, + }), +}) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index fbb31f193..8524f1f68 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -538,6 +538,7 @@ export default createModel()({ dispatch.adminUsers.reset() dispatch.adminPartners.reset() dispatch.adminEnterpriseLicenses.reset() + dispatch.adminAddonLicenses.reset() dispatch.adminNotices.reset() // ui.reset() only restores redux defaults; the live i18next/luxon locale must be // re-resolved so signed-out screens follow the OS rather than the previous diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index a99ba139d..e63cbdb60 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -5,6 +5,7 @@ import agents from './agents' import { adminPartners } from './adminPartners' import { adminUsers } from './adminUsers' import { adminEnterpriseLicenses } from './adminEnterpriseLicenses' +import { adminAddonLicenses } from './adminAddonLicenses' import adminNotices from './adminNotices' import announcements from './announcements' import applicationTypes from './applicationTypes' @@ -43,6 +44,7 @@ export interface RootModel extends Models { adminPartners: typeof adminPartners adminUsers: typeof adminUsers adminEnterpriseLicenses: typeof adminEnterpriseLicenses + adminAddonLicenses: typeof adminAddonLicenses adminNotices: typeof adminNotices announcements: typeof announcements applicationTypes: typeof applicationTypes @@ -82,6 +84,7 @@ export const models: RootModel = { adminPartners, adminUsers, adminEnterpriseLicenses, + adminAddonLicenses, adminNotices, announcements, applicationTypes, diff --git a/frontend/src/models/plans.ts b/frontend/src/models/plans.ts index 346cbd719..0bbc38f17 100644 --- a/frontend/src/models/plans.ts +++ b/frontend/src/models/plans.ts @@ -27,6 +27,9 @@ type ILicenseLookup = { productId: string; platform?: number } export const REMOTEIT_PRODUCT_ID = 'b999e047-5532-11eb-8872-063ce187bcd7' export const AWS_PRODUCT_ID = '55d9e884-05fd-11eb-bda8-021f403e8c27' +// The ai-agent ADD-ON product (graphql-api docs/AI-AGENT-LICENSE.md): a licence for it is the +// account's entitlement to Remote.It AI, granted per account from Admin → Add-ons. +export const AI_AGENT_PRODUCT_ID = '96aa515b-cf6b-40bf-8d04-7972cbbc7c39' export const PERSONAL_PLAN_ID = 'e147a026-81d7-11eb-afc8-02f048730623' export const PROFESSIONAL_PLAN_ID = '6b5e1e70-045d-11ec-8a08-02ea65a4da2d' export const BUSINESS_PLAN_ID = '85ce6edf-9e70-11ec-b51a-0a63867cb0b9' diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx new file mode 100644 index 000000000..a750443a9 --- /dev/null +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -0,0 +1,403 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory, useLocation, useParams } from 'react-router-dom' +import { Attribute } from '../../components/Attributes' +import { Confirm } from '../../components/Confirm' +import { Container } from '../../components/Container' +import { GridList } from '../../components/GridList' +import { GridListItem } from '../../components/GridListItem' +import { Gutters } from '../../components/Gutters' +import { Icon } from '../../components/Icon' +import { LoadingMessage } from '../../components/LoadingMessage' +import { removeObject } from '../../helpers/utilHelper' +import { graphQLAddAddonCustomer, graphQLRemoveAddonCustomer } from '../../services/graphQLMutation' +import { AdminAddonCustomer, AdminAddonProduct } from '../../models/adminAddonLicenses' +import { Dispatch, State } from '../../store' + +/* Add-on licences (graphql-api docs/AI-AGENT-LICENSE.md): one page for every add-on product, not + one per add-on. The product is in the URL (/admin/add-ons/:productId) so a reload, a deep link + and the sidebar's remembered route all land on the same list. ai-agent is the first product; + the next one is a product row on the API and shows up in the selector with no change here. */ + +export const ADMIN_ADDONS_ROUTE = '/admin/add-ons' + +const productLabel = (product?: AdminAddonProduct) => + product ? `${product.description || product.name}${product.enabled ? '' : ' (disabled)'}` : 'add-on' + +// `datetime-local` needs `YYYY-MM-DDTHH:mm` in local time — toISOString() would shift to UTC. +const toInputValue = (date: Date) => { + const offset = date.getTimezoneOffset() * 60000 + return new Date(date.getTime() - offset).toISOString().slice(0, 16) +} + +type AddonCustomerAttributeOptions = { + customer?: AdminAddonCustomer +} + +class AddonCustomerAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' + translate = false // internal-only admin registry: render English, skip columns.* translation +} + +const addonCustomerAttributes: AddonCustomerAttribute[] = [ + new AddonCustomerAttribute({ + id: 'email', + label: 'Account', + defaultWidth: 250, + required: true, + value: ({ customer }) => customer?.email || '-', + }), + new AddonCustomerAttribute({ + id: 'devices', + label: 'Devices', + defaultWidth: 100, + value: ({ customer }) => customer?.deviceCount ?? 0, + }), + new AddonCustomerAttribute({ + id: 'members', + label: 'Members', + defaultWidth: 100, + value: ({ customer }) => customer?.memberCount ?? 0, + }), + new AddonCustomerAttribute({ + id: 'created', + label: 'Granted', + defaultWidth: 150, + value: ({ customer }) => (customer?.created ? new Date(customer.created).toLocaleDateString() : '-'), + }), + new AddonCustomerAttribute({ + id: 'expiration', + label: 'Expires', + defaultWidth: 170, + value: ({ customer }) => { + if (!customer?.expiration) return '-' + // The row outlives its time-box (the API skips an expired licence in the limits merge but + // keeps the row until it is revoked), so say so rather than show a date that reads as future. + const date = new Date(customer.expiration) + const expired = date.getTime() < Date.now() + return ( + + {expired ? 'Expired ' : ''} + {date.toLocaleDateString()} + + ) + }, + }), +] + +export const AdminAddonLicensesListPage: React.FC = () => { + const dispatch = useDispatch() + const history = useHistory() + const location = useLocation() + const { productId: urlProductId } = useParams<{ productId?: string }>() + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) + const [grantDialogOpen, setGrantDialogOpen] = useState(false) + const [grantEmail, setGrantEmail] = useState('') + const [grantExpiration, setGrantExpiration] = useState('') + const [granting, setGranting] = useState(false) + const [removeTarget, setRemoveTarget] = useState(null) + const [removing, setRemoving] = useState(false) + const [searchInput, setSearchInput] = useState('') + + const products = useSelector((state: State) => state.adminAddonLicenses.products) + const productsLoaded = useSelector((state: State) => state.adminAddonLicenses.productsLoaded) + const productId = useSelector((state: State) => state.adminAddonLicenses.productId) + const customers = useSelector((state: State) => state.adminAddonLicenses.customers) + const loading = useSelector((state: State) => state.adminAddonLicenses.loading) + const total = useSelector((state: State) => state.adminAddonLicenses.total) + const hasMore = useSelector((state: State) => state.adminAddonLicenses.hasMore) + const searchValue = useSelector((state: State) => state.adminAddonLicenses.searchValue) + + const product = products.find(p => p.id === productId) + const label = productLabel(product) + + const listAttributes = useMemo( + () => [ + ...addonCustomerAttributes, + new AddonCustomerAttribute({ + id: 'actions', + label: '', + defaultWidth: 48, + align: 'right', + value: ({ customer }) => ( + { + e.stopPropagation() + if (customer) setRemoveTarget(customer) + }} + > + + + ), + }), + ], + [] + ) + const [required, attributes] = removeObject(listAttributes, a => a.required === true) + + useEffect(() => { + setSearchInput(searchValue) + dispatch.adminAddonLicenses.fetchProducts() + }, []) + + // The URL is the selection — once the API has confirmed it names an add-on, so a stale link never + // fires a list request that can only be refused. `select` is a no-op for the product on screen. + useEffect(() => { + if (urlProductId && products.some(p => p.id === urlProductId)) dispatch.adminAddonLicenses.select(urlProductId) + }, [urlProductId, products]) + + // No product in the URL, or one the API no longer lists: go to the product last looked at, else + // the first add-on. Waits for the product list so a deep link to a real product is never bounced. + useEffect(() => { + if (!productsLoaded || !products.length) return + if (urlProductId && products.some(p => p.id === urlProductId)) return + const saved = defaultSelection['admin']?.[ADMIN_ADDONS_ROUTE] + const remembered = products.find(p => saved === `${ADMIN_ADDONS_ROUTE}/${p.id}`) + history.replace(`${ADMIN_ADDONS_ROUTE}/${(remembered || products[0]).id}`) + }, [urlProductId, productsLoaded, products]) + + // Remember the product for the sidebar's Add-ons entry (AdminSidebarNav.handleNavClick) + useEffect(() => { + if (urlProductId) + dispatch.ui.setDefaultSelected({ key: ADMIN_ADDONS_ROUTE, value: location.pathname, accountId: 'admin' }) + }, [location.pathname]) + + // Refetch when the (committed) search term changes, skipping the initial mount. + const isInitialMount = useRef(true) + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + return + } + dispatch.adminAddonLicenses.fetch() + }, [searchValue]) + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + dispatch.adminAddonLicenses.setSearch(searchInput) + } + } + + const closeGrantDialog = () => { + setGrantDialogOpen(false) + setGrantEmail('') + setGrantExpiration('') + } + + const handleGrant = async () => { + const email = grantEmail.trim() + if (!email || !productId) return + + // 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 + + setGranting(true) + const result = await graphQLAddAddonCustomer(productId, email, expiration) + setGranting(false) + + // A refused grant (unknown email, a disabled add-on, a Stripe-owned licence) already surfaced + // the API's own message; the dialog stays open for a correction. + if (result === 'ERROR') return + if (result?.data?.data?.addAddonCustomer) { + closeGrantDialog() + dispatch.ui.set({ successMessage: `Granted ${label} to ${email}` }) + await dispatch.adminAddonLicenses.fetch() + } else { + dispatch.ui.set({ errorMessage: `Failed to grant ${label}` }) + } + } + + const handleRemove = async () => { + if (!removeTarget || !productId) return + + setRemoving(true) + const result = await graphQLRemoveAddonCustomer(productId, removeTarget.userId) + setRemoving(false) + + if (result === 'ERROR') return + if (result?.data?.data?.removeAddonCustomer) { + dispatch.ui.set({ successMessage: `Revoked ${label} from ${removeTarget.email}` }) + setRemoveTarget(null) + await dispatch.adminAddonLicenses.fetch() + } else { + dispatch.ui.set({ errorMessage: `Failed to revoke ${label}` }) + } + } + + return ( + + + history.push(`${ADMIN_ADDONS_ROUTE}/${e.target.value}`)} + sx={{ minWidth: 180 }} + > + {products.map(p => ( + + {productLabel(p)} + + ))} + + {/* A disabled add-on refuses new grants at the API; its existing ones can still be revoked. */} + {product?.enabled && ( + + )} + setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + } + > + {productsLoaded && !products.length ? ( + + + + No add-on products + + + An add-on is a product with no default plan on the API — none is defined on this stage. + + + ) : loading && customers.length === 0 ? ( + + ) : customers.length === 0 ? ( + + + + {searchValue ? `No matching ${label} licenses` : `No ${label} licenses granted`} + + + ) : ( + + {customers.map(customer => ( + } + required={required?.value({ customer })} + > + {attributes.map(attribute => ( + + {attribute.id === 'actions' ? ( + attribute.value({ customer }) + ) : ( + + {attribute.value({ customer })} + + )} + + ))} + + ))} + {hasMore && ( + + + + )} + + )} + + + Grant {label} + + setGrantEmail(e.target.value)} + sx={{ marginTop: 2 }} + /> + setGrantExpiration(e.target.value)} + helperText="Optional — blank grants it open-ended. Granting an account that already holds it replaces its expiration." + sx={{ marginTop: 2 }} + /> + + + + + + + + setRemoveTarget(null)} + > + {removeTarget && ( + <> + Are you sure you want to revoke {label} from {removeTarget.email}? The + account loses the feature immediately. + + )} + + + ) +} diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 98110fda5..0f0591204 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -63,6 +63,7 @@ import { AdminConfirmPage } from '../pages/AdminConfirmPage' import { AdminAdminsPage } from '../pages/AdminAdminsPage/AdminAdminsPage' import { AdminPartnersPage } from '../pages/AdminPartnersPage/AdminPartnersPage' import { AdminEnterpriseLicensesListPage } from '../pages/AdminEnterpriseLicensesPage/AdminEnterpriseLicensesListPage' +import { AdminAddonLicensesListPage } from '../pages/AdminAddonLicensesPage/AdminAddonLicensesListPage' import { AdminNoticesPage } from '../pages/AdminNoticesPage/AdminNoticesPage' import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' @@ -439,6 +440,9 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { + + + diff --git a/frontend/src/services/graphQLMutation.ts b/frontend/src/services/graphQLMutation.ts index 2e2234b19..23f645ae2 100644 --- a/frontend/src/services/graphQLMutation.ts +++ b/frontend/src/services/graphQLMutation.ts @@ -708,6 +708,37 @@ export async function graphQLRemoveEnterpriseCustomer(userId: string) { ) } +// Add-on licence grants. `expiration` is a String on purpose — the API parses it strictly (a +// malformed date is refused rather than read as open-ended); undefined leaves an existing grant's +// expiry alone, null clears it. +export async function graphQLAddAddonCustomer(product: string, email: string, expiration?: string | null) { + return await graphQLBasicRequest( + ` mutation AddAddonCustomer($product: String!, $email: String!, $expiration: String) { + addAddonCustomer(product: $product, email: $email, expiration: $expiration) { + productId + userId + email + name + deviceCount + memberCount + licenseId + created + expiration + } + }`, + { product, email, expiration } + ) +} + +export async function graphQLRemoveAddonCustomer(product: string, userId: string) { + return await graphQLBasicRequest( + ` mutation RemoveAddonCustomer($product: String!, $userId: String!) { + removeAddonCustomer(product: $product, userId: $userId) + }`, + { product, userId } + ) +} + export async function graphQLAdminUpdateEmail(from: string, to: string) { return await graphQLBasicRequest( ` mutation UpdateEmail($from: String!, $to: String!) { diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index bc03f8853..e6fee7d73 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -646,6 +646,52 @@ export async function graphQLAdminEnterpriseCustomers( ) } +// Add-on licences (graphql-api docs/AI-AGENT-LICENSE.md): generic over add-on products, of which +// ai-agent is the first. A product is selected on the admin page, then its holders are listed. +export async function graphQLAdminAddonProducts() { + return await graphQLBasicRequest( + ` query AdminAddonProducts { + admin { + addonProducts { + id + name + description + enabled + } + } + }` + ) +} + +export async function graphQLAdminAddonCustomers( + product: string, + options: { from?: number; size?: number }, + search?: string +) { + return await graphQLBasicRequest( + ` query AdminAddonCustomers($product: String!, $from: Int, $size: Int, $search: String) { + admin { + addonCustomers(product: $product, from: $from, size: $size, search: $search) { + items { + productId + userId + email + name + deviceCount + memberCount + licenseId + created + expiration + } + total + hasMore + } + } + }`, + { product, from: options.from || 0, size: options.size || 50, search: search || undefined } + ) +} + export async function graphQLAdminPartners() { return await graphQLBasicRequest( ` query AdminPartners { From 4732b992491f5e695ebd3530c76628f3025af3cc Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 17:24:28 -0700 Subject: [PATCH 02/13] Add-ons admin: the row names the add-on it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AdminAddonLicensesListPage.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index a750443a9..455110db7 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -26,6 +26,7 @@ import { LoadingMessage } from '../../components/LoadingMessage' import { removeObject } from '../../helpers/utilHelper' import { graphQLAddAddonCustomer, graphQLRemoveAddonCustomer } from '../../services/graphQLMutation' import { AdminAddonCustomer, AdminAddonProduct } from '../../models/adminAddonLicenses' +import { AI_AGENT_PRODUCT_ID } from '../../models/plans' import { Dispatch, State } from '../../store' /* Add-on licences (graphql-api docs/AI-AGENT-LICENSE.md): one page for every add-on product, not @@ -46,8 +47,14 @@ const toInputValue = (date: Date) => { type AddonCustomerAttributeOptions = { customer?: AdminAddonCustomer + /* The add-on the row's licence is for, looked up from the row's own productId — so the row says + what it holds without leaning on the selector above it. */ + product?: AdminAddonProduct } +// The ai-agent add-on wears the feature's own mark; any other add-on the generic one. +const addonIcon = (productId?: string) => (productId === AI_AGENT_PRODUCT_ID ? 'remote-ai' : 'puzzle-piece') + class AddonCustomerAttribute extends Attribute { type: Attribute['type'] = 'MASTER' translate = false // internal-only admin registry: render English, skip columns.* translation @@ -61,6 +68,12 @@ const addonCustomerAttributes: AddonCustomerAttribute[] = [ required: true, value: ({ customer }) => customer?.email || '-', }), + new AddonCustomerAttribute({ + id: 'addon', + label: 'Add-on', + defaultWidth: 150, + value: ({ product, customer }) => product?.description || product?.name || customer?.productId || '-', + }), new AddonCustomerAttribute({ id: 'devices', label: 'Devices', @@ -125,6 +138,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { const product = products.find(p => p.id === productId) const label = productLabel(product) + const productOf = (customer: AdminAddonCustomer) => products.find(p => p.id === customer.productId) const listAttributes = useMemo( () => [ @@ -316,8 +330,8 @@ export const AdminAddonLicensesListPage: React.FC = () => { } - required={required?.value({ customer })} + icon={} + required={required?.value({ customer, product: productOf(customer) })} > {attributes.map(attribute => ( @@ -327,7 +341,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { - {attribute.value({ customer })} + {attribute.value({ customer, product: productOf(customer) })} )} From f4ba9756dcca275da5d4e64ef8f264d1d4245341 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 17:30:38 -0700 Subject: [PATCH 03/13] =?UTF-8?q?The=20ai-agent=20licence=20is=20the=20onl?= =?UTF-8?q?y=20switch=20for=20the=20chat=20=E2=80=94=20no=20dev-build=20or?= =?UTF-8?q?=20AI-portal=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 10 +- .../2026-08-31-ai-agent-license-limit.md | 11 +- .../2026-09-14-admin-addon-licenses-page.md | 38 ++++-- frontend/src/constants.ts | 26 +--- frontend/src/hooks/useChatEnabled.ts | 7 +- frontend/src/i18n/locales/de/app.json | 1 - frontend/src/i18n/locales/en/app.json | 1 - frontend/src/i18n/locales/es/app.json | 1 - frontend/src/i18n/locales/ja/app.json | 1 - frontend/src/pages/TestPage.tsx | 114 +++++++++--------- frontend/src/selectors/organizations.ts | 29 ++--- readme.md | 5 +- 12 files changed, 116 insertions(+), 128 deletions(-) diff --git a/.env.example b/.env.example index fd08716ee..e20dc0440 100644 --- a/.env.example +++ b/.env.example @@ -35,11 +35,11 @@ OAUTH_ISSUER="https://login.dev.remote.it" # that disagrees with the OIDC resource 401s with nothing in the UI explaining why. # --- Remote.It AI chat --------------------------------------------------------------- -# The chat is a license feature ("ai-agent"), so leave this false: local dev turns that -# flag on by default, and Settings → Test Settings → Features toggles it. Set true ONLY -# for the AI portal deployment (app.ai.remote.it), which IS the AI surface — that only -# makes the flag default ON there, so it can still be switched off for testing. -VITE_CHAT_ALWAYS_ON="false" +# The chat is a license feature ("ai-agent") and nothing else switches it on: the account +# you sign in with — locally too — needs the ai-agent add-on licence, granted from +# Admin → Add-ons by a system admin. Settings → Test Settings → Features can switch it +# back off on an account that holds it. (VITE_CHAT_ALWAYS_ON, which used to default the +# flag on for the AI portal and dev builds, is retired and ignored.) # In dev, agentURL() returns the same-origin "/agent" vite proxy unless Test Settings # overrides it, so AGENT_PROXY_TARGET is the knob here: the deployed dev agent, or # http://localhost:3001 to run the ai-agent service locally. DPoP proofs are signed over diff --git a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md index 9bfdcb7b3..f078ccac3 100644 --- a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md +++ b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md @@ -3,9 +3,14 @@ **Goal:** Make the Remote.It AI chat a real licensed feature by having the API return an `ai-agent` limit, then remove the client-side scaffolding that stands in for it today. -**Status:** The CLIENT side is done and shipped on `feature/agent-chat-interface`. The API -returns no such limit yet, so the client forward-declares it. Nothing here is blocked on -more frontend work — this note is for whoever picks up the graphql-api / licensing side. +**Status:** DONE, both halves. The API side shipped 2026-09-13 as the `ai-agent` add-on +licence (graphql-api `docs/AI-AGENT-LICENSE.md`); the client cleanup below landed 2026-09-14 +with the Admin → Add-ons page (`2026-09-14-admin-addon-licenses-page.md`): `PENDING_FEATURES`, +`CHAT_ALWAYS_ON` and `VITE_CHAT_ALWAYS_ON` are gone, and the licence is the only switch — +including for dev builds and app.ai.remote.it (decision 3 resolved as "the portal paywalls": +an unlicensed account there gets the ordinary app with no chat, and the popout says +"Remote.It AI is not available for this account"). The rest of this note is the record of +what the client assumed while the limit did not exist. **Where the work lives:** the limit itself is a graphql-api + licensing change, in another repo. The only thing in THIS repo is the cleanup in the last section, which should land at diff --git a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md index a8680b700..fb42f404b 100644 --- a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -13,11 +13,11 @@ desktop half: what the page does, where it lives, and how to verify it. ## Where things stood before this branch -- **The gate already existed.** `useChatEnabled()` reads `limits['ai-agent']` through - `selectLimitsLookup` (`frontend/src/hooks/useChatEnabled.ts`), the same selector that gates - `saml`, `roles` and `tagging`. `PENDING_FEATURES` (`frontend/src/constants.ts`) still defaults the - flag ON for local dev builds and app.ai.remote.it, and the API's value wins the moment it arrives — - which it now does for any account holding the add-on licence. +- **The gate already existed — with a hole.** `useChatEnabled()` reads `limits['ai-agent']` + through `selectLimitsLookup` (`frontend/src/hooks/useChatEnabled.ts`), the same selector that + gates `saml`, `roles` and `tagging`. But `PENDING_FEATURES` (`frontend/src/constants.ts`) + defaulted the flag ON for dev builds and app.ai.remote.it, so there the chat showed with or + without a licence. - **The API was done.** graphql-api `main` carries the generic add-on admin surface — `admin.addonProducts`, `admin.addonCustomers(product, from, size, search)`, `addAddonCustomer(product, email, expiration?)`, `removeAddonCustomer(product, userId)` — with @@ -71,6 +71,18 @@ enterprise-licences page (`AdminEnterpriseLicensesListPage.tsx`) with the produc - `routers/Router.tsx` (the `/admin/*` block), `components/AdminSidebarNav.tsx` ("Add-ons"), `components/Header/Header.tsx` (root-page rule). +### The licence is the only switch + +`PENDING_FEATURES`, `CHAT_ALWAYS_ON` and `VITE_CHAT_ALWAYS_ON` are gone (the 2026-08-31 note's +"client cleanup"). `selectLimitsLookup` is built only from the limits the API returns, so an +account without the add-on has no `ai-agent` entry at all — falsy — and nothing chat-related +mounts: no header button, no docked column, no popout (it says "Remote.It AI is not available for +this account"), and the Test page's **AI Agent** section (background work, agent URL) is behind the +same gate, so the agent service is not even asked for the background status. The Test page's +Features list shows only what the licence mentions — an account holding the add-on can switch it +off there; one without it has no row and gets it granted, not toggled. This holds for a dev build +and for app.ai.remote.it alike: a developer's dev account needs the grant too. + ### The licence card - `components/LimitSetting.tsx` — `case 'ai-agent'`: "AI agent is available" when true, and **no @@ -88,10 +100,10 @@ enterprise-licences page (`AdminEnterpriseLicensesListPage.tsx`) with the produc i18n:check`. - Driving it: run the frontend against dev (`frontend/.env.local`), sign in as a **system admin** (`r3_Users.admin`), Admin → Add-ons. Grant a test account with and without an expiration; on that - account, Account → Licensing shows the "AI Agent Alpha plan" card with "AI agent is available", - and on a non-dev build the header's AI button appears (a dev build defaults the flag on — flip - the Test page override to see the licence's own value). Revoke → the card, the line and the - button go, live. Grant an unknown email → the API's message, dialog still open. + account, Account → License shows the "AI Agent Alpha plan" card with "AI agent is available", the + header's AI button appears and Test Settings lists `ai-agent`. Revoke → the card, the line, the + button and the row go, live. Grant an unknown email → the API's message, dialog still open. An + account never granted: no AI button, no docked chat, no AI Agent section on the Test page. - The API round trip is covered by e2e `addon-license.spec.ts`; a UI spec would need an admin sign-in through Permitteer, which the suite does not have — deliberately not added. @@ -103,10 +115,10 @@ page works the day it lands, and prod gets it with the branch's promotion. ## Left for later -- **`PENDING_FEATURES` cleanup** (2026-08-31 note, "Client cleanup"): the limit is real now, but - with no default row an account WITHOUT the add-on still gets no `ai-agent` limit at all, so the - forward-declared default is what keeps local dev and app.ai on. Retire it when the API sends a - default row (the GA upsell line) or app.ai gets its own floor — decision 3 in that note. +- **app.ai.remote.it for the unlicensed.** With no floor, an account without the add-on gets the + ordinary portal there, chat-less and without a word about why (the popout is the one place that + says so). If the AI portal should explain itself, that is a notice keyed on the same gate — not a + bypass. The Amplify branch env's `VITE_CHAT_ALWAYS_ON=true` is now inert and can be removed. - **The admin user-detail "License" column** (`pages/AdminUsersPage/adminUserAttributes.tsx`, a TODO) is the natural place to *show* an account's add-ons beside its plan. - **Phase 2/3** (paid tiers carrying the limit; the add-on sold through Stripe) are API-side — see diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 92afefe2f..754c063e3 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -3,32 +3,16 @@ import { CATALOGUE } from './platforms/catalogue' const env = import.meta.env export const MODE = env.MODE || 'development' -// The AI portal (app.ai.remote.it) shows the Remote.It AI chat unconditionally: it IS the -// AI surface. Set per-deployment via the Amplify branch env so the general app stays on -// the licensed gate below even if this branch's code merges elsewhere. -export const CHAT_ALWAYS_ON = env.VITE_CHAT_ALWAYS_ON === 'true' /* The license limit that gates the Remote.It AI chat. The whole surface hangs off this one name — the header button, the docked column and everything the panel loads — so - switching the feature on for an account is a licensing change rather than a release. */ + switching the feature on for an account is a licensing change rather than a release: + the ai-agent ADD-ON licence, granted per account from Admin → Add-ons (graphql-api + docs/AI-AGENT-LICENSE.md). It is the ONLY switch. Until 2026-09-14 a dev build and the + AI portal defaulted the flag on ahead of the licence (PENDING_FEATURES / CHAT_ALWAYS_ON); + now an account without the licence — a developer's included — sees no chat anywhere. */ export const CHAT_FEATURE = 'ai-agent' -/* Boolean license features this build gates on that an account's license may not carry - yet, each paired with what it is worth until a license speaks. Naming one here gives it - a row on the Test page AND puts it in the limits lookup, which is what makes it testable - at all: the lookup is built FROM the limits the API returned, so a flag the API has - never mentioned has nothing for an override to attach to. The API's value wins once it - starts arriving, so an entry whose limit has gone live is dead weight and can go. - - These are DEFAULTS, not bypasses — every one of them stays a normal feature flag, so - the Test page switch reads what is actually in effect and can turn the feature back - OFF. That is the point of routing local dev and the AI portal through here rather than - around the gate: on app.ai.remote.it the chat is on because CHAT_ALWAYS_ON makes this - default true, and it is still one switch away from off. */ -export const PENDING_FEATURES: ILookup = { - [CHAT_FEATURE]: MODE === 'development' || CHAT_ALWAYS_ON, -} - // Renderer-owned OIDC (permitteer docs/remoteit-desktop-login.md, D8) — identical on // web and desktop; the backend never touches auth. export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index fc2f5fb29..33cd0cfd9 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -24,10 +24,9 @@ import { It is a LICENSE feature, read exactly the way tagging/saml/roles are, which means it follows the ACCOUNT you are viewing: the chat is scoped to the organization in the sidebar selector, so an org whose license does not carry the agent does not get one. - Nothing skips this gate. Local dev and the AI portal (CHAT_ALWAYS_ON) only set the - flag's default where no license carries it yet (PENDING_FEATURES), so even there the - chat is a feature flag you can switch back off in Test Settings → Features — which is - also how you turn it on anywhere else until the API starts sending the limit. */ + Nothing skips this gate — not a dev build, not the AI portal. The limit comes from the + ai-agent add-on licence (Admin → Add-ons); an account holding it can still switch the + feature off in Test Settings → Features, and one without it has no row there. */ export const useChatEnabled = (): boolean => useSelector((state: State) => !!selectLimitsLookup(state)[CHAT_FEATURE]) /* The widest the chat column may be dragged: whatever the window holds once the diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 6010f3434..ab80f75e8 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2256,7 +2256,6 @@ "disableTestUI": "Test-UI deaktivieren", "disableTestUIHint": "Um die Alpha-UI wieder zu aktivieren, müssen Sie das Avatar-Menü bei gedrückter Alt-Umschalt-Taste auswählen.", "featureLabel": "{{name}} (Standard {{state}})", - "featurePending": "", "features": "Funktionen", "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 8208d4250..c7b54f2fc 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2256,7 +2256,6 @@ "disableTestUI": "Disable Test UI", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", "featureLabel": "{{name}} (default {{state}})", - "featurePending": "Not in any license yet. Switches here apply to your personal account only.", "features": "Features", "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 9050e6ac2..248b1fad0 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2293,7 +2293,6 @@ "disableTestUI": "Deshabilitar la interfaz de prueba", "disableTestUIHint": "Para volver a habilitar la interfaz alfa, debes seleccionar el menú de avatar mientras mantienes presionado alt-shift.", "featureLabel": "{{name}} (predeterminado {{state}})", - "featurePending": "", "features": "Funciones", "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index d611742fb..7f76be105 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2219,7 +2219,6 @@ "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", - "featurePending": "", "features": "機能", "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index cc00720d8..b92015031 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -11,6 +11,7 @@ import { isSecureAgentURL, backgroundConnectUrl, backgroundStatus, backgroundDis import { windowOpen } from '../services/browser' import { selectLimitsLookup, selectFeatures } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' +import { useChatEnabled } from '../hooks/useChatEnabled' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' import { ListItemSetting } from '../components/ListItemSetting' import { ListItemRadio } from '../components/ListItemRadio' @@ -50,10 +51,13 @@ export const TestPage: React.FC = () => { // grant — enrollment is a browser ceremony at the AS; this page only reads/ends it. (The // one UI entry point for it: without this control backgroundConnectUrl/backgroundStatus // have no caller and the workflow cannot be enabled.) + // Behind the same licence gate as the chat: without it the section is not shown, and the + // agent service is not asked anything. + const chatEnabled = useChatEnabled() const [backgroundEnrolled, setBackgroundEnrolled] = useState(undefined) useEffect(() => { - backgroundStatus().then(setBackgroundEnrolled) - }, []) + if (chatEnabled) backgroundStatus().then(setBackgroundEnrolled) + }, [chatEnabled]) async function connectBackground() { await windowOpen(backgroundConnectUrl(), '_blank', true) // The ceremony finishes in the browser — poll briefly for the verdict. @@ -288,53 +292,57 @@ export const TestPage: React.FC = () => { - {t('testPage.aiAgent', 'AI Agent')} - - (backgroundEnrolled ? disableBackground() : connectBackground())} - /> - - - - { - const url = result.toString().trim() - if (url && !isSecureAgentURL(url)) { - setAgentError(t('testPage.agentURLInvalid', 'Agent service URL must start with https://')) - return - } - setAgentError('') - // Reset (or entering the default) CLEARS the override so agentURL() falls back to the - // /agent proxy (dev) or VITE_AGENT_URL (build) — never pinning the OAuth audience as the transport. - setAPIPreference('agentURL', url === OAUTH_AGENT_RESOURCE ? '' : url) - }} - hideIcon - /> - {!!agentError && ( - - - {agentError} - - - )} - - - - + {chatEnabled && ( + <> + {t('testPage.aiAgent', 'AI Agent')} + + (backgroundEnrolled ? disableBackground() : connectBackground())} + /> + + + + { + const url = result.toString().trim() + if (url && !isSecureAgentURL(url)) { + setAgentError(t('testPage.agentURLInvalid', 'Agent service URL must start with https://')) + return + } + setAgentError('') + // Reset (or entering the default) CLEARS the override so agentURL() falls back to the + // /agent proxy (dev) or VITE_AGENT_URL (build) — never pinning the OAuth audience as the transport. + setAPIPreference('agentURL', url === OAUTH_AGENT_RESOURCE ? '' : url) + }} + hideIcon + /> + {!!agentError && ( + + + {agentError} + + + )} + + + + + + )} {t('testPage.features', 'Features')} {features.map(f => ( @@ -346,14 +354,6 @@ export const TestPage: React.FC = () => { state: f.value ? t('testPage.enabled', 'enabled') : t('testPage.disabled', 'disabled'), defaultValue: '{{name}} (default {{state}})', })} - subLabel={ - f.pending - ? t( - 'testPage.featurePending', - 'Not in any license yet. Switches here apply to your personal account only.' - ) - : undefined - } toggle={!!featureValues[f.name]} /* Writes the OVERRIDE, not the effective lookup. Spreading the lookup pinned every OTHER feature at its current value as well, so a later change to the diff --git a/frontend/src/selectors/organizations.ts b/frontend/src/selectors/organizations.ts index 8f8d46f28..7f944ce97 100644 --- a/frontend/src/selectors/organizations.ts +++ b/frontend/src/selectors/organizations.ts @@ -1,6 +1,5 @@ import { createSelector } from 'reselect' import { REMOTEIT_PRODUCT_ID } from '../models/plans' -import { PENDING_FEATURES } from '../constants' import { getUser, getOrganizations, @@ -98,10 +97,8 @@ export const selectLimitsLookup = createSelector( [selectLimits, isUserAccount, getLimitsOverride], (baseLimits, isUserAccount, limitsOverride): ILookup => { const result: ILookup = {} - // Flags this build knows about but no license carries yet: worth their declared - // default until the API says otherwise, and — being named — something an override can - // take a position on, which a name the lookup has never seen would not be. - Object.entries(PENDING_FEATURES).forEach(([name, value]) => (result[name] = value)) + // Built FROM the limits the API returned: a name no license has mentioned is simply + // absent (falsy), and there is nothing for a Test page override to attach to. baseLimits.forEach(l => (result[l.name] = l.value)) if (isUserAccount) Object.keys(result).forEach(name => { @@ -111,20 +108,14 @@ export const selectLimitsLookup = createSelector( } ) -export type IFeature = { name: string; value: boolean; pending?: boolean } - -/* The boolean features the Test page lists: the ones this account's license mentions, - plus the ones this build forward-declares. `pending` is the difference between "the - license said no" and "no license has mentioned it yet" — the second is a flag still - soft-launching, where the Test page switch is the only way to see the feature. */ -export const selectFeatures = createSelector([selectLimits], (limits): IFeature[] => { - const features: IFeature[] = limits - .filter(l => typeof l.value === 'boolean') - .map(l => ({ name: l.name, value: l.value as boolean })) - for (const [name, value] of Object.entries(PENDING_FEATURES)) - if (!features.some(f => f.name === name)) features.push({ name, value, pending: true }) - return features -}) +export type IFeature = { name: string; value: boolean } + +/* The boolean features the Test page lists: exactly the ones this account's license + mentions. A feature no license carries has no row — it is granted (Admin → Add-ons for + the add-ons), not switched on here. */ +export const selectFeatures = createSelector([selectLimits], (limits): IFeature[] => + limits.filter(l => typeof l.value === 'boolean').map(l => ({ name: l.name, value: l.value as boolean })) +) export const selectLicensesWithLimits = createSelector([selectLicenses, selectLimits], (licenses, limits) => { return { diff --git a/readme.md b/readme.md index ec2eb3fd8..b36f87840 100644 --- a/readme.md +++ b/readme.md @@ -134,8 +134,9 @@ why. Reach Test Settings by holding **shift+option** and clicking your avatar #### AI chat -The chat is always on where `VITE_CHAT_ALWAYS_ON=true` (the AI portal, and locally); elsewhere -it soft-launches behind the Test UI. In dev its requests go through the same-origin `/agent` +The chat is a license feature: it shows only for an account holding the `ai-agent` add-on licence, +which a system admin grants from **Admin → Add-ons** — your dev account included; nothing in a build +or env turns it on otherwise. In dev its requests go through the same-origin `/agent` vite proxy, so `AGENT_PROXY_TARGET` is what selects the service — the deployed dev agent, or `http://localhost:3001` to run `ai-agent` locally. **Settings → Test Settings → Override agent service** overrides it at runtime without a restart (https only). DPoP proofs are signed over From 67d06a2e6f69909b4dbca3e5bdffe178750b9532 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 17:33:38 -0700 Subject: [PATCH 04/13] =?UTF-8?q?Add-ons=20admin:=20the=20Grant=20button?= =?UTF-8?q?=20is=20the=20page's=20one=20action=20=E2=80=94=20contained,=20?= =?UTF-8?q?primary,=20with=20a=20plus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text button read as a label beside the selector. Same shape as the scripting header's Add. Co-Authored-By: Claude Opus 5 --- .../AdminAddonLicensesListPage.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index 455110db7..3508375f3 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -282,8 +282,15 @@ export const AdminAddonLicensesListPage: React.FC = () => { {/* A disabled add-on refuses new grants at the API; its existing ones can still be revoked. */} {product?.enabled && ( - )} Date: Mon, 14 Sep 2026 17:34:28 -0700 Subject: [PATCH 05/13] Add-ons admin: the search gets its own row under the selector and Grant Co-Authored-By: Claude Opus 5 --- .../AdminAddonLicensesListPage.tsx | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index 3508375f3..4c3144abc 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -264,35 +264,38 @@ export const AdminAddonLicensesListPage: React.FC = () => { bodyProps={{ verticalOverflow: true, horizontalOverflow: true }} header={ - - history.push(`${ADMIN_ADDONS_ROUTE}/${e.target.value}`)} - sx={{ minWidth: 180 }} - > - {products.map(p => ( - - {productLabel(p)} - - ))} - - {/* A disabled add-on refuses new grants at the API; its existing ones can still be revoked. */} - {product?.enabled && ( - - )} + {products.map(p => ( + + {productLabel(p)} + + ))} + + {/* A disabled add-on refuses new grants at the API; its existing ones can still be revoked. */} + {product?.enabled && ( + + )} + Date: Mon, 14 Sep 2026 17:53:28 -0700 Subject: [PATCH 06/13] Add-ons admin: a page that lands after its list was superseded is dropped (Codex round 1, P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/models/adminAddonLicenses.test.ts | 97 +++++++++++++++++++ frontend/src/models/adminAddonLicenses.ts | 45 ++++++++- .../AdminAddonLicensesListPage.tsx | 13 +-- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts index a9e4a3100..b743caca5 100644 --- a/frontend/src/models/adminAddonLicenses.test.ts +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -19,9 +19,17 @@ const makeDispatch = () => ({ setCustomers: vi.fn(), appendCustomers: vi.fn(), setLoading: vi.fn(), + setSearchValue: vi.fn(), + resetState: vi.fn(), fetch: vi.fn(), }, }) +// A request the test resolves by hand, to interleave events with a page in flight. +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise(r => (resolve = r)) + return { promise, resolve } +} const stateWith = (over: Record = {}) => ({ adminAddonLicenses: { ...model.state, ...over }, }) @@ -122,4 +130,93 @@ describe('adminAddonLicenses effects', () => { await effectsFor(dispatch).fetchMore(undefined, stateWith({ productId: 'p1', hasMore: true, loading: true })) expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() }) + + it('setSearch commits the term and refetches for it', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).setSearch('ann') + expect(dispatch.adminAddonLicenses.setSearchValue).toHaveBeenCalledWith('ann') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + }) + + it('reset clears the state', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).reset() + expect(dispatch.adminAddonLicenses.resetState).toHaveBeenCalledTimes(1) + }) +}) + +/* The races: a page that lands after the list it was fetched for has been superseded — by a + newer request, a product switch, a new search term or sign-out — must not be written. */ +describe('adminAddonLicenses stale responses', () => { + it("a product switch retires the old product's page: it never lands under the new product", async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const a = deferred() + const b = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise) + + const forA = effects.fetch(undefined, stateWith({ productId: 'A' })) + const forB = effects.fetch(undefined, stateWith({ productId: 'B' })) // what select(B) issues + b.resolve(page([holder('b1')], 1, false)) + a.resolve(page([holder('a1')], 1, false)) // A's answer arrives last + await Promise.all([forA, forB]) + + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledTimes(1) + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledWith({ + customers: [holder('b1')], + total: 1, + hasMore: false, + }) + }) + + it('a refresh that lands under a Load More retires it: the paged rows are not appended to the new list', async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const more = deferred() + const fresh = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(more.promise).mockReturnValueOnce(fresh.promise) + + const paging = effects.fetchMore(undefined, stateWith({ productId: 'A', customers: [holder('a1')], hasMore: true })) + const refreshing = effects.fetch(undefined, stateWith({ productId: 'A', customers: [holder('a1')] })) + fresh.resolve(page([holder('a1')], 1, false)) + more.resolve(page([holder('a2')], 2, false)) + await Promise.all([paging, refreshing]) + + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledTimes(1) + expect(dispatch.adminAddonLicenses.appendCustomers).not.toHaveBeenCalled() + }) + + it('a superseded request leaves the spinner to the request that owns it', async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const first = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(first.promise).mockResolvedValueOnce(page([], 0, false)) + + const stale = effects.fetch(undefined, stateWith({ productId: 'A' })) + await effects.fetch(undefined, stateWith({ productId: 'A' })) + dispatch.adminAddonLicenses.setLoading.mockClear() + first.resolve('ERROR') // a refused stale request must not clear the newer one's spinner either + await stale + + expect(dispatch.adminAddonLicenses.setLoading).not.toHaveBeenCalled() + }) + + it('sign-out retires every request in flight, the product list included', async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const list = deferred() + const products = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(list.promise) + graphQLAdminAddonProducts.mockReturnValueOnce(products.promise) + + const listing = effects.fetch(undefined, stateWith({ productId: 'A' })) + const loadingProducts = effects.fetchProducts() + await effects.reset() + list.resolve(page([holder('a1')], 1, false)) + products.resolve({ data: { data: { admin: { addonProducts: [{ id: 'A', name: 'a', enabled: true }] } } } }) + await Promise.all([listing, loadingProducts]) + + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + expect(dispatch.adminAddonLicenses.setProducts).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts index 1790f209b..b01eb657c 100644 --- a/frontend/src/models/adminAddonLicenses.ts +++ b/frontend/src/models/adminAddonLicenses.ts @@ -56,6 +56,17 @@ const initialState: AdminAddonLicensesState = { type Page = { customers: AdminAddonCustomer[]; total: number; hasMore: boolean } +/* Latest-wins tickets. Every request takes one before its await and writes only if it is still + the newest when the response lands; every event that makes an in-flight page meaningless — a + product switch, a new search, sign-out — and every newer request takes the next number. One + ticket covers the whole list, first page and Load More alike, because they invalidate each + other: a refresh that lands under a Load More would otherwise be appended to by rows paged off + the list it replaced. (Comparing the response's product and search to the store at resolve time + would let exactly that through — they still match.) The product list has its own, invalidated + only by sign-out: it is not scoped to a selection, and two of its responses say the same thing. */ +let listRequest = 0 +let productsRequest = 0 + export const adminAddonLicenses = createModel()({ name: 'adminAddonLicenses', state: initialState, @@ -79,28 +90,38 @@ export const adminAddonLicenses = createModel()({ loading: false, }), setLoading: (state, loading: boolean) => ({ ...state, loading }), - setSearch: (state, searchValue: string) => ({ ...state, searchValue }), - reset: () => initialState, + setSearchValue: (state, searchValue: string) => ({ ...state, searchValue }), + resetState: () => initialState, }, effects: dispatch => ({ async fetchProducts() { + const ticket = ++productsRequest const result = await graphQLAdminAddonProducts() - if (result === 'ERROR') return + if (ticket !== productsRequest || result === 'ERROR') return const products: AdminAddonProduct[] = result?.data?.data?.admin?.addonProducts || [] dispatch.adminAddonLicenses.setProducts(products) }, - /* The page's selection. The list belongs to one product, so a new product fetches afresh; - re-selecting the current one is a no-op (the URL effect fires on every render of the route). */ + /* The page's selection. The list belongs to one product, so a new product fetches afresh — + 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 dispatch.adminAddonLicenses.setProductId(productId) await dispatch.adminAddonLicenses.fetch() }, + /* A committed search term: the list is refetched for it, which retires the page in flight for + the old term. */ + async setSearch(searchValue: string) { + dispatch.adminAddonLicenses.setSearchValue(searchValue) + await dispatch.adminAddonLicenses.fetch() + }, + async fetch(_: void, rootState) { const state = rootState.adminAddonLicenses if (!state.productId) return + const ticket = ++listRequest dispatch.adminAddonLicenses.setLoading(true) const result = await graphQLAdminAddonCustomers( @@ -109,6 +130,10 @@ export const adminAddonLicenses = createModel()({ state.searchValue.trim() || undefined ) + // Superseded: a newer request, or an event that retired this one, owns the list (and the + // spinner) now — this response describes a list nobody is looking at. + if (ticket !== listRequest) return + if (result !== 'ERROR' && result?.data?.data?.admin?.addonCustomers) { const data = result.data.data.admin.addonCustomers dispatch.adminAddonLicenses.setCustomers({ @@ -124,6 +149,7 @@ export const adminAddonLicenses = createModel()({ async fetchMore(_: void, rootState) { const state = rootState.adminAddonLicenses if (!state.productId || !state.hasMore || state.loading) return + const ticket = ++listRequest dispatch.adminAddonLicenses.setLoading(true) const result = await graphQLAdminAddonCustomers( @@ -132,6 +158,8 @@ export const adminAddonLicenses = createModel()({ state.searchValue.trim() || undefined ) + if (ticket !== listRequest) return + if (result !== 'ERROR' && result?.data?.data?.admin?.addonCustomers) { const data = result.data.data.admin.addonCustomers dispatch.adminAddonLicenses.appendCustomers({ @@ -143,5 +171,12 @@ export const adminAddonLicenses = createModel()({ dispatch.adminAddonLicenses.setLoading(false) } }, + + // Sign-out: nothing in flight may land in the next session's state. + async reset() { + ++listRequest + ++productsRequest + dispatch.adminAddonLicenses.resetState() + }, }), }) diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index 4c3144abc..dd0a34b6c 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -12,7 +12,7 @@ import { TextField, Typography, } from '@mui/material' -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory, useLocation, useParams } from 'react-router-dom' import { Attribute } from '../../components/Attributes' @@ -193,16 +193,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { dispatch.ui.setDefaultSelected({ key: ADMIN_ADDONS_ROUTE, value: location.pathname, accountId: 'admin' }) }, [location.pathname]) - // Refetch when the (committed) search term changes, skipping the initial mount. - const isInitialMount = useRef(true) - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false - return - } - dispatch.adminAddonLicenses.fetch() - }, [searchValue]) - + // Enter commits the term; the model refetches for it (and retires the page in flight). const handleSearchKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { dispatch.adminAddonLicenses.setSearch(searchInput) From 9c4291937eb3829460946c081178c11f95a4bf9a Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 18:01:03 -0700 Subject: [PATCH 07/13] Add-ons admin: each dialog acts on what it was opened for, not the live selection (Codex round 2, P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AdminAddonLicensesListPage.tsx | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index dd0a34b6c..d86f50a12 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -119,7 +119,12 @@ export const AdminAddonLicensesListPage: React.FC = () => { const { productId: urlProductId } = useParams<{ productId?: string }>() const columnWidths = useSelector((state: State) => state.ui.columnWidths) const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) - const [grantDialogOpen, setGrantDialogOpen] = useState(false) + /* Each dialog acts on what it was OPENED for, not on the selection at the moment it is confirmed: + the grant dialog captures the product, and the revoke confirm takes the product from the row. + The URL can move the selection while a dialog is up — Back/Forward, or the product refresh + redirecting off a product the API dropped — and a mutation built from the live selection would + then hit product B under a title that said A. Both dialogs also close when that happens. */ + const [grantFor, setGrantFor] = useState(null) const [grantEmail, setGrantEmail] = useState('') const [grantExpiration, setGrantExpiration] = useState('') const [granting, setGranting] = useState(false) @@ -139,6 +144,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { const product = products.find(p => p.id === productId) const label = productLabel(product) const productOf = (customer: AdminAddonCustomer) => products.find(p => p.id === customer.productId) + const removeLabel = removeTarget ? productLabel(productOf(removeTarget)) : label const listAttributes = useMemo( () => [ @@ -201,14 +207,21 @@ export const AdminAddonLicensesListPage: React.FC = () => { } const closeGrantDialog = () => { - setGrantDialogOpen(false) + setGrantFor(null) setGrantEmail('') setGrantExpiration('') } + // The selection moved: whatever a dialog was about is no longer on screen. + useEffect(() => { + setRemoveTarget(null) + closeGrantDialog() + }, [productId]) + const handleGrant = async () => { const email = grantEmail.trim() - if (!email || !productId) return + if (!email || !grantFor) return + const grantLabel = productLabel(grantFor) // 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 @@ -216,7 +229,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null setGranting(true) - const result = await graphQLAddAddonCustomer(productId, email, expiration) + const result = await graphQLAddAddonCustomer(grantFor.id, email, expiration) setGranting(false) // A refused grant (unknown email, a disabled add-on, a Stripe-owned licence) already surfaced @@ -224,27 +237,28 @@ export const AdminAddonLicensesListPage: React.FC = () => { if (result === 'ERROR') return if (result?.data?.data?.addAddonCustomer) { closeGrantDialog() - dispatch.ui.set({ successMessage: `Granted ${label} to ${email}` }) + dispatch.ui.set({ successMessage: `Granted ${grantLabel} to ${email}` }) await dispatch.adminAddonLicenses.fetch() } else { - dispatch.ui.set({ errorMessage: `Failed to grant ${label}` }) + dispatch.ui.set({ errorMessage: `Failed to grant ${grantLabel}` }) } } const handleRemove = async () => { - if (!removeTarget || !productId) return + if (!removeTarget) return setRemoving(true) - const result = await graphQLRemoveAddonCustomer(productId, removeTarget.userId) + // The row's own product — the licence being revoked is the one the row showed + const result = await graphQLRemoveAddonCustomer(removeTarget.productId, removeTarget.userId) setRemoving(false) if (result === 'ERROR') return if (result?.data?.data?.removeAddonCustomer) { - dispatch.ui.set({ successMessage: `Revoked ${label} from ${removeTarget.email}` }) + dispatch.ui.set({ successMessage: `Revoked ${removeLabel} from ${removeTarget.email}` }) setRemoveTarget(null) await dispatch.adminAddonLicenses.fetch() } else { - dispatch.ui.set({ errorMessage: `Failed to revoke ${label}` }) + dispatch.ui.set({ errorMessage: `Failed to revoke ${removeLabel}` }) } } @@ -276,7 +290,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { {/* A disabled add-on refuses new grants at the API; its existing ones can still be revoked. */} {product?.enabled && ( + + ) : !products.length && productsStatus !== 'loaded' ? ( + + ) : !products.length ? ( + + ) : !product ? ( + // the redirect above is choosing one + ) : !customers.length && listStatus === 'failed' ? ( + + + + ) : !customers.length && listStatus !== 'loaded' ? ( - ) : customers.length === 0 ? ( - - - - {searchValue ? `No matching ${label} licenses` : `No ${label} licenses granted`} - - + ) : !customers.length ? ( + ) : ( {customers.map(customer => ( From 54b526897a1391fac754312cecd4bf4595234949 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 18:28:04 -0700 Subject: [PATCH 10/13] Add-ons admin tests: a mock call is unknown[], not a one-tuple (typecheck) Co-Authored-By: Claude Opus 5 --- frontend/src/models/adminAddonLicenses.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts index 1452c2202..ace80cefd 100644 --- a/frontend/src/models/adminAddonLicenses.test.ts +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -95,7 +95,7 @@ describe('adminAddonLicenses effects', () => { } expect(dispatch.adminAddonLicenses.setProducts).not.toHaveBeenCalled() expect( - dispatch.adminAddonLicenses.setProductsStatus.mock.calls.filter(([s]: [string]) => s === 'failed') + dispatch.adminAddonLicenses.setProductsStatus.mock.calls.filter((call: unknown[]) => call[0] === 'failed') ).toHaveLength(3) }) From ac4b7e2a7cb1c5ba0da9cfddc2ab809572b5ebfa Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 18:42:51 -0700 Subject: [PATCH 11/13] The background-work control outlives the licence; the list's identity includes the API target (Codex round 5, P1 + P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-09-14-admin-addon-licenses-page.md | 6 +++-- frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + .../src/models/adminAddonLicenses.test.ts | 24 ++++++++++++++++++- frontend/src/models/adminAddonLicenses.ts | 16 ++++++++++++- frontend/src/pages/TestPage.tsx | 21 ++++++++++++---- 8 files changed, 62 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md index 7381e393d..27460ef66 100644 --- a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -85,8 +85,10 @@ enterprise-licences page (`AdminEnterpriseLicensesListPage.tsx`) with the produc account without the add-on has no `ai-agent` entry at all — falsy — and nothing chat-related mounts: no header button, no docked column, no popout (it says "Remote.It AI is not available for this account"), and the Test page's **AI Agent** section (background work, agent URL) is behind the -same gate, so the agent service is not even asked for the background status. The Test page's -Features list shows only what the licence mentions — an account holding the add-on can switch it +same gate — with one exception: the agent's **background-work grant** is its own standing at the AS +and outlives the entitlement (sign-out revokes it explicitly for that reason), so the section also +shows, unlicensed, while such a grant exists, keeping the one control that ends it reachable. The +Test page's Features list shows only what the licence mentions — an account holding the add-on can switch it off there; one without it has no row and gets it granted, not toggled. This holds for a dev build and for app.ai.remote.it alike: a developer's dev account needs the grant too. diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 047be7c03..0f06f10f7 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2247,6 +2247,7 @@ "backgroundWork": "", "backgroundWorkOff": "", "backgroundWorkOn": "", + "backgroundWorkOrphaned": "", "backgroundWorkUnknown": "", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 83ccf9778..d3ec8422f 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2247,6 +2247,7 @@ "backgroundWork": "AI background work", "backgroundWorkOff": "The agent only works while you are here.", "backgroundWorkOn": "The agent can read and watch while you are away.", + "backgroundWorkOrphaned": "The agent can still read and watch while you are away, though this account no longer has Remote.It AI — switch it off.", "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index f3fe86fe4..de7e0bfb0 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2284,6 +2284,7 @@ "backgroundWork": "", "backgroundWorkOff": "", "backgroundWorkOn": "", + "backgroundWorkOrphaned": "", "backgroundWorkUnknown": "", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index bf19f31c5..8ce08aa81 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2210,6 +2210,7 @@ "backgroundWork": "", "backgroundWorkOff": "", "backgroundWorkOn": "", + "backgroundWorkOrphaned": "", "backgroundWorkUnknown": "", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts index ace80cefd..226db49e5 100644 --- a/frontend/src/models/adminAddonLicenses.test.ts +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // The model touches nothing but the two request wrappers; stub those and drive the effects and // reducers directly, the way chat.test.ts does. -const { graphQLAdminAddonProducts, graphQLAdminAddonCustomers } = vi.hoisted(() => ({ +const { graphQLAdminAddonProducts, graphQLAdminAddonCustomers, getApiURL } = vi.hoisted(() => ({ graphQLAdminAddonProducts: vi.fn(), graphQLAdminAddonCustomers: vi.fn(), + getApiURL: vi.fn(() => 'https://cloud.dev.remote.it/api/graphql'), })) vi.mock('../services/graphQLRequest', () => ({ graphQLAdminAddonProducts, graphQLAdminAddonCustomers })) +vi.mock('../helpers/apiHelper', () => ({ getApiURL })) import { adminAddonLicenses } from './adminAddonLicenses' @@ -30,6 +32,7 @@ const makeDispatch = () => ({ setCustomers: vi.fn(), appendCustomers: vi.fn(), setProductsStatus: vi.fn(), + setTarget: vi.fn(), setListStatus: vi.fn(), setSearchValue: vi.fn(), resetState: vi.fn(), @@ -75,6 +78,16 @@ describe('adminAddonLicenses reducers', () => { const before = { ...model.state, productId: 'a', listStatus: 'loaded' } expect(model.reducers.setProductId(before, 'b')).toMatchObject({ listStatus: 'idle', customers: [] }) }) + + it('a new API target empties the list exactly like a new product; the same target keeps it', () => { + const before = { ...model.state, target: 'dev', customers: [holder('u1')], total: 1, listStatus: 'loaded' } + expect(model.reducers.setTarget(before, 'prod')).toMatchObject({ + target: 'prod', + customers: [], + listStatus: 'idle', + }) + expect(model.reducers.setTarget(before, 'dev')).toBe(before) + }) }) describe('adminAddonLicenses effects', () => { @@ -99,6 +112,15 @@ describe('adminAddonLicenses effects', () => { ).toHaveLength(3) }) + it('refresh stamps the current API target before anything is awaited — rows from another target leave at once', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + getApiURL.mockReturnValueOnce('https://cloud.remote.it/api/graphql') + graphQLAdminAddonProducts.mockReturnValueOnce(new Promise(() => {})) // never answers + void effects.refresh('A', stateWith({ productId: 'A', target: 'https://cloud.dev.remote.it/api/graphql' })) + expect(dispatch.adminAddonLicenses.setTarget).toHaveBeenCalledWith('https://cloud.remote.it/api/graphql') + }) + it("refresh takes the URL's product when the catalogue lists it, and fetches its list afresh", async () => { const dispatch = makeDispatch() const effects = withRealEffects(dispatch, { productId: 'B' }) diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts index e1d784793..3e98c2c41 100644 --- a/frontend/src/models/adminAddonLicenses.ts +++ b/frontend/src/models/adminAddonLicenses.ts @@ -1,5 +1,6 @@ import { createModel } from '@rematch/core' import { graphQLAdminAddonCustomers, graphQLAdminAddonProducts } from '../services/graphQLRequest' +import { getApiURL } from '../helpers/apiHelper' import type { RootModel } from '.' /* Admin grants of ADD-ON licences (graphql-api docs/AI-AGENT-LICENSE.md) — generic over add-on @@ -41,6 +42,10 @@ interface AdminAddonLicensesState { products: AdminAddonProduct[] productsStatus: LoadStatus productId?: string + /* The API target (graphql URL) the rows were fetched from. Part of the list's identity with the + product: Test Settings switches the target without a reload, a product id is the same on every + stage, and rows from the other stage must not sit on screen — interactive — behind the same id. */ + target?: string customers: AdminAddonCustomer[] total: number hasMore: boolean @@ -53,6 +58,7 @@ const initialState: AdminAddonLicensesState = { products: [], productsStatus: 'idle', productId: undefined, + target: undefined, customers: [], total: 0, hasMore: false, @@ -85,6 +91,11 @@ export const adminAddonLicenses = createModel()({ productId === state.productId ? state : { ...state, productId, customers: [], total: 0, hasMore: false, listStatus: 'idle' as const }, + // A new target empties the list the same way a new product does: nothing on screen is from here. + setTarget: (state, target?: string) => + target === state.target + ? state + : { ...state, target, customers: [], total: 0, hasMore: false, listStatus: 'idle' as const }, setListStatus: (state, listStatus: LoadStatus) => ({ ...state, listStatus }), setCustomers: (state, payload: Page) => ({ ...state, @@ -131,9 +142,12 @@ export const adminAddonLicenses = createModel()({ hands the choice back to the page, which redirects to one that exists), then that product's list, fetched AFRESH. Always afresh: the page can remount over rows from another API target (Test Settings switches the stage without reloading, and cloudSync.all() knows nothing of - this model), and a product id is the same on every stage. A switch's request retires + this model), and a product id is the same on every stage — so the target is checked FIRST, + before anything is awaited: rows from another target leave the screen at once rather than + 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()) const products = await dispatch.adminAddonLicenses.fetchProducts() if (!products) return diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index b92015031..6d995b8a3 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -51,13 +51,19 @@ export const TestPage: React.FC = () => { // grant — enrollment is a browser ceremony at the AS; this page only reads/ends it. (The // one UI entry point for it: without this control backgroundConnectUrl/backgroundStatus // have no caller and the workflow cannot be enabled.) - // Behind the same licence gate as the chat: without it the section is not shown, and the - // agent service is not asked anything. + // Behind the chat's licence gate — with one exception. The background grant is the agent's OWN + // standing at the AS and outlives the entitlement (which is why sign-out revokes it explicitly), + // so losing the licence — revoked, expired, or switched off in Features below — must not take + // the only control that can end it. The status is therefore always asked (one GET, on this staff + // page only), and the section shows while the feature is licensed OR a grant is still standing; + // an unlicensed account with no grant sees nothing. Revoking on the entitlement flipping off + // would be wrong: it also flips when merely viewing an organization without the add-on. const chatEnabled = useChatEnabled() const [backgroundEnrolled, setBackgroundEnrolled] = useState(undefined) useEffect(() => { - if (chatEnabled) backgroundStatus().then(setBackgroundEnrolled) - }, [chatEnabled]) + backgroundStatus().then(setBackgroundEnrolled) + }, []) + const showAgentSettings = chatEnabled || !!backgroundEnrolled async function connectBackground() { await windowOpen(backgroundConnectUrl(), '_blank', true) // The ceremony finishes in the browser — poll briefly for the verdict. @@ -292,7 +298,7 @@ export const TestPage: React.FC = () => { - {chatEnabled && ( + {showAgentSettings && ( <> {t('testPage.aiAgent', 'AI Agent')} @@ -302,6 +308,11 @@ export const TestPage: React.FC = () => { subLabel={ backgroundEnrolled === undefined ? t('testPage.backgroundWorkUnknown', 'Checking…') + : backgroundEnrolled && !chatEnabled + ? t( + 'testPage.backgroundWorkOrphaned', + 'The agent can still read and watch while you are away, though this account no longer has Remote.It AI — switch it off.' + ) : backgroundEnrolled ? t('testPage.backgroundWorkOn', 'The agent can read and watch while you are away.') : t('testPage.backgroundWorkOff', 'The agent only works while you are here.') From 3f4c51878982e12291d2f843b1d076ef636a1993 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 18:55:45 -0700 Subject: [PATCH 12/13] The Test page's AI section is licence-gated, full stop; a target change retires the page in flight (Codex round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-09-14-admin-addon-licenses-page.md | 12 ++++++---- frontend/src/i18n/locales/de/app.json | 1 - frontend/src/i18n/locales/en/app.json | 1 - frontend/src/i18n/locales/es/app.json | 1 - frontend/src/i18n/locales/ja/app.json | 1 - .../src/models/adminAddonLicenses.test.ts | 16 +++++++++++++ frontend/src/models/adminAddonLicenses.ts | 6 ++++- frontend/src/pages/TestPage.tsx | 24 +++++++------------ 8 files changed, 37 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md index 27460ef66..cd44183eb 100644 --- a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -85,10 +85,14 @@ enterprise-licences page (`AdminEnterpriseLicensesListPage.tsx`) with the produc account without the add-on has no `ai-agent` entry at all — falsy — and nothing chat-related mounts: no header button, no docked column, no popout (it says "Remote.It AI is not available for this account"), and the Test page's **AI Agent** section (background work, agent URL) is behind the -same gate — with one exception: the agent's **background-work grant** is its own standing at the AS -and outlives the entitlement (sign-out revokes it explicitly for that reason), so the section also -shows, unlicensed, while such a grant exists, keeping the one control that ends it reachable. The -Test page's Features list shows only what the licence mentions — an account holding the add-on can switch it +same gate, so the agent service is not even asked for the background status. A standing +**background-work grant** — the agent's own OAuth grant at the AS, which outlives the entitlement +(sign-out revokes it explicitly for that reason) — is ended from Account → Connected Apps, which is +not gated on the licence and kills every token minted from the grant; the Test page toggle is a +convenience for licensed accounts, not the grant's only door. (Whether the agent should refuse +*background work* for an account whose licence lapsed is the agent service's question — it gates on +nothing licence-shaped today, and no background scheduler exists yet.) The Test page's Features +list shows only what the licence mentions — an account holding the add-on can switch it off there; one without it has no row and gets it granted, not toggled. This holds for a dev build and for app.ai.remote.it alike: a developer's dev account needs the grant too. diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 0f06f10f7..047be7c03 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2247,7 +2247,6 @@ "backgroundWork": "", "backgroundWorkOff": "", "backgroundWorkOn": "", - "backgroundWorkOrphaned": "", "backgroundWorkUnknown": "", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index d3ec8422f..83ccf9778 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2247,7 +2247,6 @@ "backgroundWork": "AI background work", "backgroundWorkOff": "The agent only works while you are here.", "backgroundWorkOn": "The agent can read and watch while you are away.", - "backgroundWorkOrphaned": "The agent can still read and watch while you are away, though this account no longer has Remote.It AI — switch it off.", "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index de7e0bfb0..f3fe86fe4 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2284,7 +2284,6 @@ "backgroundWork": "", "backgroundWorkOff": "", "backgroundWorkOn": "", - "backgroundWorkOrphaned": "", "backgroundWorkUnknown": "", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 8ce08aa81..bf19f31c5 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2210,7 +2210,6 @@ "backgroundWork": "", "backgroundWorkOff": "", "backgroundWorkOn": "", - "backgroundWorkOrphaned": "", "backgroundWorkUnknown": "", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts index 226db49e5..00913d1bb 100644 --- a/frontend/src/models/adminAddonLicenses.test.ts +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -121,6 +121,22 @@ describe('adminAddonLicenses effects', () => { expect(dispatch.adminAddonLicenses.setTarget).toHaveBeenCalledWith('https://cloud.remote.it/api/graphql') }) + it("a target change retires the other target's page in flight: it cannot refill the emptied list", async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + const oldTargetPage = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(oldTargetPage.promise) + const inFlight = effects.fetch(undefined, stateWith({ productId: 'A', target: 'dev' })) + + getApiURL.mockReturnValueOnce('prod') + graphQLAdminAddonProducts.mockReturnValueOnce(new Promise(() => {})) // the catalogue is still being awaited + void effects.refresh('A', stateWith({ productId: 'A', target: 'dev' })) + oldTargetPage.resolve(page([holder('dev-user')], 1, false)) + await inFlight + + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + }) + it("refresh takes the URL's product when the catalogue lists it, and fetches its list afresh", async () => { const dispatch = makeDispatch() const effects = withRealEffects(dispatch, { productId: 'B' }) diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts index 3e98c2c41..0e735dff2 100644 --- a/frontend/src/models/adminAddonLicenses.ts +++ b/frontend/src/models/adminAddonLicenses.ts @@ -147,7 +147,11 @@ export const adminAddonLicenses = createModel()({ 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()) + const target = getApiURL() + // A page still in flight from the other target is retired with its rows — it would otherwise + // pass the ticket check and refill the emptied list while the catalogue is awaited. + if (target !== rootState.adminAddonLicenses.target) ++listRequest + dispatch.adminAddonLicenses.setTarget(target) const products = await dispatch.adminAddonLicenses.fetchProducts() if (!products) return diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 6d995b8a3..e6249112d 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -51,19 +51,16 @@ export const TestPage: React.FC = () => { // grant — enrollment is a browser ceremony at the AS; this page only reads/ends it. (The // one UI entry point for it: without this control backgroundConnectUrl/backgroundStatus // have no caller and the workflow cannot be enabled.) - // Behind the chat's licence gate — with one exception. The background grant is the agent's OWN - // standing at the AS and outlives the entitlement (which is why sign-out revokes it explicitly), - // so losing the licence — revoked, expired, or switched off in Features below — must not take - // the only control that can end it. The status is therefore always asked (one GET, on this staff - // page only), and the section shows while the feature is licensed OR a grant is still standing; - // an unlicensed account with no grant sees nothing. Revoking on the entitlement flipping off - // would be wrong: it also flips when merely viewing an organization without the add-on. + // Behind the chat's licence gate: without it the section is not shown and the agent service is + // not asked anything. This toggle is a convenience, not the grant's only door: the background + // grant is an OAuth grant held at the AS for the agent's own client, and Account → Connected Apps + // (not gated on the licence) lists and revokes it — killing every token minted from it — whether + // or not this account still has Remote.It AI, and whether or not the agent service answers. const chatEnabled = useChatEnabled() const [backgroundEnrolled, setBackgroundEnrolled] = useState(undefined) useEffect(() => { - backgroundStatus().then(setBackgroundEnrolled) - }, []) - const showAgentSettings = chatEnabled || !!backgroundEnrolled + if (chatEnabled) backgroundStatus().then(setBackgroundEnrolled) + }, [chatEnabled]) async function connectBackground() { await windowOpen(backgroundConnectUrl(), '_blank', true) // The ceremony finishes in the browser — poll briefly for the verdict. @@ -298,7 +295,7 @@ export const TestPage: React.FC = () => { - {showAgentSettings && ( + {chatEnabled && ( <> {t('testPage.aiAgent', 'AI Agent')} @@ -308,11 +305,6 @@ export const TestPage: React.FC = () => { subLabel={ backgroundEnrolled === undefined ? t('testPage.backgroundWorkUnknown', 'Checking…') - : backgroundEnrolled && !chatEnabled - ? t( - 'testPage.backgroundWorkOrphaned', - 'The agent can still read and watch while you are away, though this account no longer has Remote.It AI — switch it off.' - ) : backgroundEnrolled ? t('testPage.backgroundWorkOn', 'The agent can read and watch while you are away.') : t('testPage.backgroundWorkOff', 'The agent only works while you are here.') From 3b180429ee24a21a0d4d2958c76ad3eed0f76d09 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 14 Sep 2026 19:06:03 -0700 Subject: [PATCH 13/13] Add-ons admin: every screen keys on the statuses, none on products.length (Codex round 7, P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-09-14-admin-addon-licenses-page.md | 8 +++++ .../AdminAddonLicensesListPage.tsx | 33 ++++++++++++------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md index cd44183eb..49d91df72 100644 --- a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -128,6 +128,14 @@ page works the day it lands, and prod gets it with the branch's promotion. ## Left for later +- **Expiry is enforced at the next sync, not at the second.** A time-boxed grant that lapses while + the grantee's app stays open keeps its cached `ai-agent` limit until the desktop next refetches + limits (a licence event, a reconnect, a refresh) — exactly as every other licensed feature behaves + when its licence expires. The real enforcement point is server-side: the agent service gates on + nothing licence-shaped today, and neither does the MCP surface (graphql-api + `docs/AI-AGENT-LICENSE.md`, "Exposure"). A client-side timer would only paper over that; the + server check is the fix, and once it exists the client's lazy refresh is merely cosmetic. + - **app.ai.remote.it for the unlicensed.** With no floor, an account without the add-on gets the ordinary portal there, chat-less and without a word about why (the popout is the one place that says so). If the AI portal should explain itself, that is a notice keyed on the same gate — not a diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index 07dd82d2f..dc52a6c55 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -159,6 +159,16 @@ export const AdminAddonLicensesListPage: React.FC = () => { const product = products.find(p => p.id === productId) const label = productLabel(product) + /* What is on screen is worth showing only if the list ANSWERED for it, or rows are held (a failed + Load More keeps them). Decides between the grid and the four "nothing to list" screens below — + never products.length or customers.length alone: a stale catalogue survives a failed refresh + (fetchProducts keeps what it held), and rows survive a failed page. */ + const listUsable = listStatus === 'loaded' || customers.length > 0 + const retry = ( + + ) const productOf = (customer: AdminAddonCustomer) => products.find(p => p.id === customer.productId) const removeLabel = removeTarget ? productLabel(productOf(removeTarget)) : label @@ -337,15 +347,16 @@ export const AdminAddonLicensesListPage: React.FC = () => { } > - {/* 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' ? ( + {/* Each screen is decided by the STATUSES, never by an empty array alone — "nothing has + answered yet", "the answer was no", and "nobody holds it" are different screens. With + nothing usable on screen, a catalogue that failed to answer is the screen even when a + stale catalogue is held (a target switch empties the rows first, then its refresh can fail); + with something usable, the rows stay and the failure is the snackbar. */} + {!listUsable && productsStatus === 'failed' ? ( - + {retry} - ) : !products.length && productsStatus !== 'loaded' ? ( + ) : !listUsable && productsStatus !== 'loaded' ? ( ) : !products.length ? ( { /> ) : !product ? ( // the redirect above is choosing one - ) : !customers.length && listStatus === 'failed' ? ( + ) : !listUsable && listStatus === 'failed' ? ( - + {retry} - ) : !customers.length && listStatus !== 'loaded' ? ( + ) : !listUsable ? ( ) : !customers.length ? (