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 new file mode 100644 index 000000000..49d91df72 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -0,0 +1,147 @@ +# 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 — 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 + `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` — the catalogue, the selected product and the paginated holder + list, each with a load STATUS (`idle | loading | loaded | failed`) kept apart from what it last + delivered, so the page tells "nothing has answered yet" from "nobody holds it". `refresh(urlProduct)` + is the one way in — on mount, on every move of the URL's product, and from the header's refresh + button: catalogue first, the selection checked against it (a product the API stopped listing is + cleared and the page redirects), then the list fetched afresh (a remount can sit over rows from + another API target — Test Settings switches the stage without a reload). Every request carries a + latest-wins ticket, so a page that lands after its list was superseded (a product switch, a new + search, a refresh under a Load More, sign-out) is dropped. Registered in `models/index.ts`, reset + on sign-out in `models/auth.ts`. +- `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 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. 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. + +### 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 → 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. + +## 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 + +- **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 + 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 + 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..9e5ded271 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<{ productId?: string }>('/admin/add-ons/:productId?') 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' + // One call: the catalogue, the selection re-checked against it, then the list + methods.push(async () => await dispatch.adminAddonLicenses.refresh(adminAddonLicensesPage.params.productId)) + // 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/LicensingSetting.tsx b/frontend/src/components/LicensingSetting.tsx index 2f02f892b..6cbe4e687 100644 --- a/frontend/src/components/LicensingSetting.tsx +++ b/frontend/src/components/LicensingSetting.tsx @@ -26,15 +26,18 @@ export const LicensingSetting: React.FC<{ licenses: ILicense[]; limits?: ILimit[ } secondary={ - !license.id ? ( - t('licensingSetting.notSubscribed', 'Not subscribed') - ) : ( - license.expiration && ( - <> - {t('licensingSetting.renews', 'Renews')} - - ) - ) + !license.id + ? t('licensingSetting.notSubscribed', 'Not subscribed') + : license.expiration && ( + <> + {/* Billing renews a SUBSCRIBED licence at this date. Any other licence with an + expiration — an admin-granted add-on's time-box, a custom term — ends there. */} + {license.subscription + ? t('licensingSetting.renews', 'Renews') + : t('licensingSetting.expires', 'Expires')}{' '} + + + ) } /> 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/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 7f41cd0f8..047be7c03 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1108,11 +1108,13 @@ "title": "Lizenz" }, "licensingSetting": { + "expires": "", "notSubscribed": "Nicht abonniert", "plan": "Plan", "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", @@ -2255,7 +2257,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 b4fde2029..83ccf9778 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1108,11 +1108,13 @@ "title": "License" }, "licensingSetting": { + "expires": "Expires", "notSubscribed": "Not subscribed", "plan": "plan", "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", @@ -2255,7 +2257,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 2ef27e54b..f3fe86fe4 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1119,11 +1119,13 @@ "title": "Licencia" }, "licensingSetting": { + "expires": "", "notSubscribed": "Sin suscripción", "plan": "plan", "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", @@ -2292,7 +2294,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 7bd87ab9d..bf19f31c5 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1097,11 +1097,13 @@ "title": "ライセンス" }, "licensingSetting": { + "expires": "", "notSubscribed": "未契約", "plan": "プラン", "renews": "更新" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "メールサポートが利用可能です", "evaluationPeriod": "サービスには{{period}}の評価期間が付与されます", "forumSupport": "フォーラムサポートのみ", @@ -2218,7 +2220,6 @@ "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", - "featurePending": "", "features": "機能", "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts new file mode 100644 index 000000000..00913d1bb --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -0,0 +1,320 @@ +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, 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' + +const model = adminAddonLicenses as any +const effectsFor = (dispatch: any) => model.effects(dispatch) +// refresh dispatches fetchProducts and fetch through the model; the fake routes those to the +// real effects so a refresh test exercises the whole way in. +const withRealEffects = (dispatch: any, state: Record = {}) => { + const effects = effectsFor(dispatch) + dispatch.adminAddonLicenses.fetchProducts = () => effects.fetchProducts() + dispatch.adminAddonLicenses.fetch = vi.fn(() => effects.fetch(undefined, stateWith(state))) + return effects +} +const catalogue = (...ids: string[]) => ({ + data: { data: { admin: { addonProducts: ids.map(id => ({ id, name: id, enabled: true })) } } }, +}) +const makeDispatch = () => ({ + adminAddonLicenses: { + setProducts: vi.fn(), + setProductId: vi.fn(), + setCustomers: vi.fn(), + appendCustomers: vi.fn(), + setProductsStatus: vi.fn(), + setTarget: vi.fn(), + setListStatus: 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 }, +}) +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: [], productsStatus: 'loaded' }) + }) + + it('a new product resets the list to never-asked, so the page shows loading rather than empty', () => { + 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', () => { + it('fetchProducts stores what the API lists and resolves to it', async () => { + const dispatch = makeDispatch() + const products = [{ id: 'p1', name: 'ai-agent', description: 'AI Agent', enabled: true }] + graphQLAdminAddonProducts.mockResolvedValueOnce({ data: { data: { admin: { addonProducts: products } } } }) + await expect(effectsFor(dispatch).fetchProducts()).resolves.toEqual(products) + expect(dispatch.adminAddonLicenses.setProductsStatus).toHaveBeenCalledWith('loading') + expect(dispatch.adminAddonLicenses.setProducts).toHaveBeenCalledWith(products) + }) + + it('a refused or missing response (offline, no auth yet) is not an empty product list — the list held stays, marked failed', async () => { + const dispatch = makeDispatch() + for (const answer of ['ERROR', undefined, { data: { data: { admin: {} } } }]) { + graphQLAdminAddonProducts.mockResolvedValueOnce(answer) + await expect(effectsFor(dispatch).fetchProducts()).resolves.toBeUndefined() + } + expect(dispatch.adminAddonLicenses.setProducts).not.toHaveBeenCalled() + expect( + dispatch.adminAddonLicenses.setProductsStatus.mock.calls.filter((call: unknown[]) => call[0] === 'failed') + ).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("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' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A', 'B')) + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([holder('b1')], 1, false)) + await effects.refresh('B', stateWith({ productId: 'A', customers: [holder('a1')] })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith('B') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + expect(graphQLAdminAddonCustomers).toHaveBeenCalledWith('B', { from: 0, size: 50 }, undefined) + }) + + it('refresh keeps the product held when the URL names none, and refetches even a loaded list (another API target may have filled it)', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A')) + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([], 0, false)) + await effects.refresh(undefined, stateWith({ productId: 'A', customers: [holder('stale')], listStatus: 'loaded' })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith('A') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + }) + + it('refresh clears a selection the catalogue no longer lists and asks for no list — the page picks a product that exists', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'gone' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A')) + await effects.refresh('gone', stateWith({ productId: 'gone' })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith(undefined) + expect(dispatch.adminAddonLicenses.fetch).not.toHaveBeenCalled() + expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + }) + + it('refresh touches neither the selection nor the list when the catalogue did not answer', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(undefined) + await effects.refresh('A', stateWith({ productId: 'A' })) + expect(dispatch.adminAddonLicenses.setProductId).not.toHaveBeenCalled() + expect(dispatch.adminAddonLicenses.fetch).not.toHaveBeenCalled() + }) + + 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.setListStatus).toHaveBeenCalledWith('loading') + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledWith({ + customers: [holder('u1')], + total: 7, + hasMore: true, + }) + }) + + it('a refused or missing list marks the list failed and leaves the rows alone', async () => { + const dispatch = makeDispatch() + for (const answer of ['ERROR', undefined]) { + graphQLAdminAddonCustomers.mockResolvedValueOnce(answer) + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: 'p1' })) + expect(dispatch.adminAddonLicenses.setListStatus).toHaveBeenLastCalledWith('failed') + } + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + }) + + 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, listStatus: 'loading' }) + ) + 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 refresh(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 list's status 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.setListStatus.mockClear() + first.resolve('ERROR') // a refused stale request must not mark the newer one's list failed either + await stale + + expect(dispatch.adminAddonLicenses.setListStatus).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 new file mode 100644 index 000000000..0e735dff2 --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.ts @@ -0,0 +1,234 @@ +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 + 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 +} + +/* Where a request stands, kept apart from what it last delivered. `idle` = never asked; `failed` = + the last ask got no usable answer (offline, no auth header yet, a refused query) — whatever was + delivered before is kept, so the page decides what to show from the status AND the rows, never + from an empty array alone: "empty because nobody holds it" and "empty because nothing has + answered yet" are different screens. */ +export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'failed' + +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 + listStatus: LoadStatus + pageSize: number + searchValue: string +} + +const initialState: AdminAddonLicensesState = { + products: [], + productsStatus: 'idle', + productId: undefined, + target: undefined, + customers: [], + total: 0, + hasMore: false, + listStatus: 'idle', + pageSize: 50, + searchValue: '', +} + +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, + reducers: { + setProductsStatus: (state, productsStatus: LoadStatus) => ({ ...state, productsStatus }), + setProducts: (state, products: AdminAddonProduct[]) => ({ ...state, products, productsStatus: 'loaded' as const }), + // 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, 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, + customers: payload.customers, + total: payload.total, + hasMore: payload.hasMore, + listStatus: 'loaded' as const, + }), + appendCustomers: (state, payload: Page) => ({ + ...state, + customers: [...state.customers, ...payload.customers], + total: payload.total, + hasMore: payload.hasMore, + listStatus: 'loaded' as const, + }), + setSearchValue: (state, searchValue: string) => ({ ...state, searchValue }), + resetState: () => initialState, + }, + effects: dispatch => ({ + /* The product catalogue. Resolves to the fresh list, or undefined when nothing answered — the + products held stay, marked failed. */ + async fetchProducts(): Promise { + const ticket = ++productsRequest + dispatch.adminAddonLicenses.setProductsStatus('loading') + const result = await graphQLAdminAddonProducts() + if (ticket !== productsRequest) return undefined + + // No response at all (offline, no auth header yet) is not an empty list. + const products: AdminAddonProduct[] | undefined = + result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonProducts + if (!Array.isArray(products)) { + dispatch.adminAddonLicenses.setProductsStatus('failed') + return undefined + } + + dispatch.adminAddonLicenses.setProducts(products) + return products + }, + + /* The ONE way in — the page on mount and on every move of the URL's product, and the refresh + button: the catalogue first, then the selection checked against it (the URL's product when it + names one that exists, else the one held if it still exists, else none — a product the API + no longer lists cannot stay selected, or every list request for it is refused; clearing it + 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 — 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) { + 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 + + const held = rootState.adminAddonLicenses.productId + const listed = (id?: string) => !!id && products.some(p => p.id === id) + const productId = listed(preferredProductId) ? preferredProductId : listed(held) ? held : undefined + dispatch.adminAddonLicenses.setProductId(productId) + if (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.setListStatus('loading') + + const result = await graphQLAdminAddonCustomers( + state.productId, + { from: 0, size: state.pageSize }, + state.searchValue.trim() || undefined + ) + + // Superseded: a newer request, or an event that retired this one, owns the list (and its + // status) now — this response describes a list nobody is looking at. + if (ticket !== listRequest) return + + const data = result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonCustomers + if (data) { + dispatch.adminAddonLicenses.setCustomers({ + customers: data.items || [], + total: data.total || 0, + hasMore: !!data.hasMore, + }) + } else { + dispatch.adminAddonLicenses.setListStatus('failed') + } + }, + + async fetchMore(_: void, rootState) { + const state = rootState.adminAddonLicenses + if (!state.productId || !state.hasMore || state.listStatus === 'loading') return + const ticket = ++listRequest + dispatch.adminAddonLicenses.setListStatus('loading') + + const result = await graphQLAdminAddonCustomers( + state.productId, + { from: state.customers.length, size: state.pageSize }, + state.searchValue.trim() || undefined + ) + + if (ticket !== listRequest) return + + const data = result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonCustomers + if (data) { + dispatch.adminAddonLicenses.appendCustomers({ + customers: data.items || [], + total: data.total || 0, + hasMore: !!data.hasMore, + }) + } else { + // The rows held stay; the page keeps its Load More for another try. + dispatch.adminAddonLicenses.setListStatus('failed') + } + }, + + // 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/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..dc52a6c55 --- /dev/null +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -0,0 +1,469 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material' +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' +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 { 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 + 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) +} + +const Empty: React.FC<{ title: string; body?: string; children?: React.ReactNode }> = ({ title, body, children }) => ( + + + + {title} + + {body && ( + + {body} + + )} + {children} + +) + +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 +} + +const addonCustomerAttributes: AddonCustomerAttribute[] = [ + new AddonCustomerAttribute({ + id: 'email', + label: 'Account', + defaultWidth: 250, + 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', + 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) + /* 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) + const [removeTarget, setRemoveTarget] = useState(null) + const [removing, setRemoving] = useState(false) + const [searchInput, setSearchInput] = useState('') + + const products = useSelector((state: State) => state.adminAddonLicenses.products) + const productsStatus = useSelector((state: State) => state.adminAddonLicenses.productsStatus) + const productId = useSelector((state: State) => state.adminAddonLicenses.productId) + const customers = useSelector((state: State) => state.adminAddonLicenses.customers) + const listStatus = useSelector((state: State) => state.adminAddonLicenses.listStatus) + const loading = listStatus === '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) + /* 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 + + 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) + }, []) + + // The URL is the selection, and refresh is the one way in: on mount and on every move of the + // product it re-reads the catalogue, checks the product against it, and fetches the list afresh + // — so a stale link never fires a list request that can only be refused, and a remount never + // shows rows fetched from another API target. + useEffect(() => { + dispatch.adminAddonLicenses.refresh(urlProductId) + }, [urlProductId]) + + // 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 catalogue so a deep link to a real product is never bounced. + useEffect(() => { + if (productsStatus !== 'loaded' || !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, productsStatus, 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]) + + // 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) + } + } + + const closeGrantDialog = () => { + 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 || !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 + // form shows, not silently keep the old date. + const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null + + setGranting(true) + 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 + // 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 ${grantLabel} to ${email}` }) + await dispatch.adminAddonLicenses.fetch() + } else { + dispatch.ui.set({ errorMessage: `Failed to grant ${grantLabel}` }) + } + } + + const handleRemove = async () => { + if (!removeTarget) return + + setRemoving(true) + // 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 ${removeLabel} from ${removeTarget.email}` }) + setRemoveTarget(null) + await dispatch.adminAddonLicenses.fetch() + } else { + dispatch.ui.set({ errorMessage: `Failed to revoke ${removeLabel}` }) + } + } + + return ( + + {/* Two rows: which add-on and its one action, then the search over that add-on's holders */} + + + 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: ( + + + + ), + }} + /> + + + } + > + {/* 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} + + ) : !listUsable && productsStatus !== 'loaded' ? ( + + ) : !products.length ? ( + + ) : !product ? ( + // the redirect above is choosing one + ) : !listUsable && listStatus === 'failed' ? ( + + {retry} + + ) : !listUsable ? ( + + ) : !customers.length ? ( + + ) : ( + + {customers.map(customer => ( + } + required={required?.value({ customer, product: productOf(customer) })} + > + {attributes.map(attribute => ( + + {attribute.id === 'actions' ? ( + attribute.value({ customer }) + ) : ( + + {attribute.value({ customer, product: productOf(customer) })} + + )} + + ))} + + ))} + {hasMore && ( + + + + )} + + )} + + + Grant {productLabel(grantFor ?? undefined)} + + 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 {removeLabel} from {removeTarget.email}? + The account loses the feature immediately. + + )} + + + ) +} diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index cc00720d8..e6249112d 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,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: 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) - }, []) + 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 +295,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 +357,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/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/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/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 { 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