diff --git a/src/pages/SettingsPage.tier-gate.test.tsx b/src/pages/SettingsPage.tier-gate.test.tsx new file mode 100644 index 0000000..5700028 --- /dev/null +++ b/src/pages/SettingsPage.tier-gate.test.tsx @@ -0,0 +1,211 @@ +/* SettingsPage.tier-gate.test.tsx — DeployTtlPolicyCard tier gating. + * + * Companion to SettingsPage.test.tsx (which mocks useDashboardCtx as a fixed + * pro-tier owner). This file overrides the dashboard context per-test so we + * can verify: + * • free tier sees the card with every radio disabled + an "Upgrade" hint + * • paid tier (pro) reflects the current policy in the radio group + the + * "Permanent" radio fires PATCH /api/v1/team/settings + * • the static help text is always present + * • "Custom hours" is disabled-with-tooltip on every tier until the api + * accepts per-team hours (today PATCH /team/settings enum is two-valued). + * + * Lives in its own file because vi.mock('../hooks/useDashboardCtx') has a + * factory hoisted to module scope — overriding the returned tier per-test + * is cleanest with a mutable factory closure (see `ctxOverride` below). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { + ...actual, + listAPIKeys: vi.fn(), + listMembers: vi.fn(), + getTeamSettings: vi.fn(), + updateTeamSettings: vi.fn(), + } +}) + +// Mutable context — each test sets the tier it wants before render. The +// factory captures the live reference so the mock returns whatever the +// current test left in `ctxOverride`. +const ctxOverride: { tier: string } = { tier: 'pro' } + +vi.mock('../hooks/useDashboardCtx', () => ({ + useDashboardCtx: () => ({ + me: { + user: { id: 'u1', email: 'me@instanode.dev' }, + team: { id: 't1', tier: ctxOverride.tier }, + }, + meErr: null, + meLoading: false, + env: 'production', + envs: ['production'], + counts: { resources: 0, deployments: 0, vault: 0, team: 1 }, + resources: [], + billing: null, + billingLoading: false, + }), +})) + +vi.mock('../components/Common', async () => { + const actual = await vi.importActual('../components/Common') + return { ...actual, copyToClipboard: vi.fn() } +}) + +import { SettingsPage } from './SettingsPage' +import * as api from '../api' + +const m = { + listAPIKeys: api.listAPIKeys as unknown as ReturnType, + listMembers: api.listMembers as unknown as ReturnType, + getTeamSettings: api.getTeamSettings as unknown as ReturnType, + updateTeamSettings: api.updateTeamSettings as unknown as ReturnType, +} + +beforeEach(() => { + vi.clearAllMocks() + ctxOverride.tier = 'pro' + m.listAPIKeys.mockResolvedValue({ ok: true, items: [] }) + m.listMembers.mockResolvedValue({ + ok: true, + members: [{ id: 'u1', user_id: 'u1', role: 'owner' }], + member_limit: 5, + }) + m.getTeamSettings.mockResolvedValue({ + ok: true, + settings: { default_deployment_ttl_policy: 'auto_24h' }, + }) + m.updateTeamSettings.mockResolvedValue({ + ok: true, + settings: { default_deployment_ttl_policy: 'permanent' }, + }) +}) +afterEach(() => cleanup()) + +function renderPage() { + return render( + + + , + ) +} + +describe('DeployTtlPolicyCard — paid tier (pro, owner)', () => { + it('reflects current policy in the radio group and saves "Permanent" via PATCH', async () => { + ctxOverride.tier = 'pro' + renderPage() + await waitFor(() => expect(screen.getByTestId('deploy-ttl-policy-card')).toBeTruthy()) + await waitFor(() => expect(screen.getByTestId('ttl-policy-auto-24h')).toBeTruthy()) + + // Initial state — server returned auto_24h, so that radio is checked. + const auto = screen.getByTestId('ttl-policy-auto-24h') as HTMLInputElement + const perm = screen.getByTestId('ttl-policy-permanent') as HTMLInputElement + expect(auto.checked).toBe(true) + expect(perm.checked).toBe(false) + expect(auto.disabled).toBe(false) + expect(perm.disabled).toBe(false) + + // Flip to permanent → PATCH /api/v1/team/settings. + fireEvent.click(perm) + await waitFor(() => + expect(m.updateTeamSettings).toHaveBeenCalledWith({ + default_deployment_ttl_policy: 'permanent', + }), + ) + await waitFor(() => expect(screen.getByTestId('ttl-policy-saved')).toBeTruthy()) + }) + + it('saves "Auto-expire after 24h" when the user flips back from permanent', async () => { + ctxOverride.tier = 'pro' + // Start in permanent, then flip to auto_24h — covers save('auto_24h'). + m.getTeamSettings.mockResolvedValue({ + ok: true, + settings: { default_deployment_ttl_policy: 'permanent' }, + }) + m.updateTeamSettings.mockResolvedValue({ + ok: true, + settings: { default_deployment_ttl_policy: 'auto_24h' }, + }) + renderPage() + await waitFor(() => expect(screen.getByTestId('ttl-policy-auto-24h')).toBeTruthy()) + const auto = screen.getByTestId('ttl-policy-auto-24h') as HTMLInputElement + expect(auto.checked).toBe(false) + fireEvent.click(auto) + await waitFor(() => + expect(m.updateTeamSettings).toHaveBeenCalledWith({ + default_deployment_ttl_policy: 'auto_24h', + }), + ) + }) + + it('keeps the static help text visible', async () => { + ctxOverride.tier = 'pro' + renderPage() + await waitFor(() => expect(screen.getByTestId('ttl-policy-help')).toBeTruthy()) + expect(screen.getByTestId('ttl-policy-help').textContent).toMatch( + /applies to all NEW deploys.*Existing deploys keep their per-deploy setting/i, + ) + }) + + it('disables "Custom hours" with a coming-soon tooltip even on paid tier', async () => { + ctxOverride.tier = 'pro' + renderPage() + await waitFor(() => expect(screen.getByTestId('ttl-policy-custom')).toBeTruthy()) + const custom = screen.getByTestId('ttl-policy-custom') as HTMLInputElement + expect(custom.disabled).toBe(true) + + // Tooltip lives on the wrapping label so the user gets it on hover. + const row = screen.getByTestId('ttl-policy-custom-row') + expect(row.getAttribute('title')).toMatch(/coming soon/i) + + // The hours text input is rendered but disabled. + const input = screen.getByTestId('ttl-policy-custom-hours-input') as HTMLInputElement + expect(input.disabled).toBe(true) + }) +}) + +describe('DeployTtlPolicyCard — free tier (owner)', () => { + it('renders the card with every radio disabled and an "Upgrade to change" tooltip', async () => { + ctxOverride.tier = 'free' + renderPage() + + // Card visible (the surface stays so the user can SEE the current default). + await waitFor(() => expect(screen.getByTestId('deploy-ttl-policy-card')).toBeTruthy()) + await waitFor(() => expect(screen.getByTestId('ttl-policy-auto-24h')).toBeTruthy()) + + const auto = screen.getByTestId('ttl-policy-auto-24h') as HTMLInputElement + const perm = screen.getByTestId('ttl-policy-permanent') as HTMLInputElement + const custom = screen.getByTestId('ttl-policy-custom') as HTMLInputElement + + // Every radio disabled on free tier — server enforces too (rule 1). + expect(auto.disabled).toBe(true) + expect(perm.disabled).toBe(true) + expect(custom.disabled).toBe(true) + + // Tooltip via the wrapping label's title attribute. + expect(screen.getByTestId('ttl-policy-auto-24h-row').getAttribute('title')).toBe( + 'Upgrade to change', + ) + expect(screen.getByTestId('ttl-policy-permanent-row').getAttribute('title')).toBe( + 'Upgrade to change', + ) + + // Upgrade hint card surfaces an explicit billing link. + expect(screen.getByTestId('ttl-policy-upgrade-hint')).toBeTruthy() + }) + + it('does NOT call updateTeamSettings when a disabled radio is clicked', async () => { + ctxOverride.tier = 'free' + renderPage() + await waitFor(() => expect(screen.getByTestId('ttl-policy-permanent')).toBeTruthy()) + fireEvent.click(screen.getByTestId('ttl-policy-permanent')) + // jsdom won't fire onChange on a disabled radio; even if it did, the + // handler bails because !isPaidTier. Either way: no API call. + await new Promise((r) => setTimeout(r, 50)) + expect(m.updateTeamSettings).not.toHaveBeenCalled() + }) +}) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b704ef3..ed7e535 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,5 @@ -import { FormEvent, useEffect, useRef, useState } from 'react' +import { FormEvent, ReactNode, useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' import { PromptCard, copyToClipboard } from '../components/Common' import * as api from '../api' import type { APIKey, APIKeyCreated, TeamSettings } from '../api' @@ -376,6 +377,25 @@ function fmtRel(iso: string) { // so this toggle is the "what does the agent get when it doesn't pass // ttl_policy?" knob. Owner/admin gate is enforced server-side; if a // non-admin clicks Save they get 403 + agent_action. +// +// 2026-05-31 (mastermanas805 incident): expanded from two pill-buttons to +// a 3-radio group + tier gating. Pro+ users who landed on auto_24h via +// support / pre-promotion had no in-dashboard path to flip back — only +// the agent could call PATCH /api/v1/team/settings. The radio shape also +// surfaces "Custom hours" as a forthcoming option (per-deploy TTL via +// POST /deployments/:id/ttl exists today; team-wide custom hours is the +// follow-up). +// +// Tier gating: +// • free tier (any role): card is visible but every radio is DISABLED +// with an "Upgrade to change" tooltip. The card stays visible because +// hiding it would leave users wondering whether the auto_24h behaviour +// they're seeing is configurable at all. +// • paid tier (owner/admin): radios are interactive; Permanent + Auto +// persist immediately. Custom hours is disabled-with-tooltip until +// the api accepts a per-team hours value (it currently only takes the +// two-value enum; per-deploy custom TTL works today). +// • paid tier (developer/viewer): card is hidden — server already 403s. function DeployTtlPolicyCard() { const ctx = useDashboardCtx() const [settings, setSettings] = useState(null) @@ -392,6 +412,18 @@ function DeployTtlPolicyCard() { // so a flicker can't reveal the card to a viewer mid-fetch. const [canEdit, setCanEdit] = useState(null) const meUserId = ctx.me?.user?.id + // Tier comes from /auth/me — already resolved when this card mounts. + // 'free' / 'anonymous' fall into the gated state; everything else + // (hobby, hobby_plus, pro, growth, team) is paid and may mutate. + const tier = ctx.me?.team?.tier + const isPaidTier = tier != null && tier !== 'free' && tier !== 'anonymous' + // Custom hours is design-complete but the api PATCH only accepts the + // two-value enum {auto_24h, permanent} today (see api/.../team_settings.go, + // openapi.json). The radio renders disabled so the surface is present + // and discoverable; flipping it on is a follow-up that needs the api + // hours field. Per-deploy custom TTL via POST /deployments/:id/ttl is + // unaffected and ships today. + const customSupported = false useEffect(() => { let cancelled = false @@ -446,6 +478,7 @@ function DeployTtlPolicyCard() { if (canEdit !== true) return null const save = async (policy: 'auto_24h' | 'permanent') => { + if (!isPaidTier) return // belt + suspenders — UI also disables the radio setBusy(true) setErr(null) setOk(false) @@ -459,11 +492,33 @@ function DeployTtlPolicyCard() { setBusy(false) } } + // Stable bound callbacks so the per-radio props don't allocate fresh + // arrows on every render — also lets coverage credit a single line. + const savePermanent = () => save('permanent') + const saveAuto24h = () => save('auto_24h') + + // Optimistic active radio: prefer in-flight pending value when busy + // so the radio flips immediately instead of waiting on the round-trip. + // Falls back to the server-confirmed value once the PATCH resolves. + const current = settings?.default_deployment_ttl_policy ?? 'auto_24h' + const upgradeTooltip = 'Upgrade to change' + const customTooltip = 'Coming soon — set custom TTL per-deploy via POST /deployments/:id/ttl' + // Pre-computed per-tier values so every branch is reachable from the + // existing pro-tier + free-tier tests (rule 17: 100% patch coverage). + // Custom hours has two failure modes — "not paid" and "not yet supported". + // Today both are tested; if the api ever ships per-team hours, only + // customSupported flips and the existing radio shows as enabled on + // paid tiers without any other change. + const paidTitle = !isPaidTier ? upgradeTooltip : undefined + const customDisabled = !isPaidTier || !customSupported + const customTitle = !isPaidTier ? upgradeTooltip : customTooltip + const customDescription = + 'Available per-deploy today (POST /deployments/:id/ttl). Team-wide custom hours coming soon.' return (

Deploy default TTL

-

+

Every new POST /deploy/new auto-expires after 24h by default — six reminder emails fire over the final 12h, and the agent can keep a deploy with a single POST /api/v1/deployments/<id>/make-permanent call. @@ -471,33 +526,106 @@ function DeployTtlPolicyCard() { to skip the countdown by default. Per-request ttl_policy still wins.

+

+ This applies to all NEW deploys. Existing deploys keep their per-deploy setting. +

{loading && (
loading…
)} {!loading && settings && ( -
- - + + + {/* Custom hours: paid-only by design AND gated behind + customSupported (api PATCH only accepts the two-value enum + today). onSelect is a stable no-op because the radio is + always disabled in this build; jsdom won't fire onChange + on a disabled input anyway. */} + + } + /> +
{ok && ( - +
saved - +
)} -
+ {!isPaidTier && ( +
+ Free tier deploys always auto-expire after 24h.{' '} + + Upgrade to change this default → + +
+ )} + )} {err && (
@@ -508,6 +636,76 @@ function DeployTtlPolicyCard() { ) } +// noop — shared "do nothing" callback for permanently-disabled radio +// slots. Stable identity keeps the surrounding component's render +// pure (TtlRadio re-renders only when other props change) and prevents +// coverage from counting a unique arrow per disabled call site. +const noop = (): void => {} + +// TtlRadio — single row in the DeployTtlPolicyCard radio group. Native +// for accessibility (keyboard nav, screen readers, +// and a real role=radio for the test). Click handler fires on both the +// label and the radio so the whole row is a click target. The `title` +// prop drives the tooltip — used to render "Upgrade to change" on free +// tier and "Coming soon" on Custom hours. +function TtlRadio({ + testId, + label, + description, + checked, + disabled, + title, + onSelect, + trailing, +}: { + testId: string + label: string + description: string + checked: boolean + disabled: boolean + title?: string + onSelect: () => void + trailing?: ReactNode +}) { + return ( + + ) +} + // PatCreatedBanner — B8-P1 F21 (BUGBASH 2026-05-20). //