Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md
Original file line number Diff line number Diff line change
@@ -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 <date>" 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.
7 changes: 7 additions & 0 deletions frontend/src/buttons/RefreshButton/RefreshButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const RefreshButton: React.FC<ButtonProps> = 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?')
Expand Down Expand Up @@ -140,6 +141,12 @@ export const RefreshButton: React.FC<ButtonProps> = 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'
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/AdminSidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ export const AdminSidebarNav: React.FC = () => {
<ListItemText primary="Enterprise" />
</ListItemButton>

<ListItemButton
dense
selected={currentPath.includes('/admin/add-ons')}
onClick={() => handleNavClick('/admin/add-ons')}
>
<ListItemIcon>
<Icon name="puzzle-piece" size="md" />
</ListItemIcon>
<ListItemText primary="Add-ons" />
</ListItemButton>

<ListItemButton
dense
selected={currentPath.includes('/admin/notices')}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export const Header: React.FC<Props> = ({ 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 (
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/LicensingIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 <Icon name="remote-ai" size="lg" />

switch (license.id) {
case AWS_PRODUCT_ID:
name = 'aws'
Expand Down
21 changes: 12 additions & 9 deletions frontend/src/components/LicensingSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@ export const LicensingSetting: React.FC<{ licenses: ILicense[]; limits?: ILimit[
</Typography>
}
secondary={
!license.id ? (
t('licensingSetting.notSubscribed', 'Not subscribed')
) : (
license.expiration && (
<>
{t('licensingSetting.renews', 'Renews')} <Timestamp date={license.expiration} variant="long" />
</>
)
)
!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')}{' '}
<Timestamp date={license.expiration} variant="long" />
</>
)
}
/>
</ListItem>
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/LimitSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading