From e89b792c8c570e1716ace3791044f1052cce5f4b Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 16:48:24 -0400 Subject: [PATCH 01/16] Theme the Stripe payment form for dark mode (#2007) * Theme the Stripe payment form for dark mode * gql api changed - running codegen --- .../admin/Billing/AddPaymentMethod.tsx | 46 +++++++- src/context/Theme.tsx | 3 + src/gql-types/graphql.ts | 110 +++++++++++++++++- src/gql-types/schema.graphql | 104 +++++++++++++++++ 4 files changed, 261 insertions(+), 2 deletions(-) diff --git a/src/components/admin/Billing/AddPaymentMethod.tsx b/src/components/admin/Billing/AddPaymentMethod.tsx index 2786ff727a..baa1b352eb 100644 --- a/src/components/admin/Billing/AddPaymentMethod.tsx +++ b/src/components/admin/Billing/AddPaymentMethod.tsx @@ -1,6 +1,6 @@ import type { Stripe } from '@stripe/stripe-js'; -import { Box, Button, Dialog, DialogTitle } from '@mui/material'; +import { Box, Button, Dialog, DialogTitle, useTheme } from '@mui/material'; import { usePostHog } from '@posthog/react'; import { Elements } from '@stripe/react-stripe-js'; @@ -13,6 +13,7 @@ import { INTENT_SECRET_ERROR, INTENT_SECRET_LOADING, } from 'src/components/admin/Billing/shared'; +import { stripePaymentFormFieldBackgroundDark } from 'src/context/Theme'; import { fireGtmEvent } from 'src/services/gtm'; interface Props { @@ -34,6 +35,10 @@ function AddPaymentMethod({ }: Props) { const intl = useIntl(); const postHog = usePostHog(); + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const flatField = { border: 'none', boxShadow: 'none' }; const enable = setupIntentSecret !== INTENT_SECRET_LOADING && @@ -76,6 +81,45 @@ function AddPaymentMethod({ options={{ clientSecret: setupIntentSecret, loader: 'auto', + appearance: { + theme: isDark ? 'night' : 'stripe', + variables: { + colorPrimary: theme.palette.primary.main, + fontFamily: theme.typography.fontFamily, + borderRadius: `6px`, + focusBoxShadow: 'none', + focusOutline: 'none', + }, + ...(isDark && { + rules: { + '.Input': { + ...flatField, + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + '.Tab': { + ...flatField, + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + '.Tab--focused': { + borderColor: + theme.palette.primary.main, + }, + '.Block': { + ...flatField, + padding: '14px', + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + '.PickerItem': { + ...flatField, + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + }, + }), + }, }} > {!tenant ? null : ( diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index d44e28a9c9..a17b4ef300 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -461,6 +461,9 @@ export const opaqueLightModeBorder = { dark: undefined, }; +// TODO need to consolidate lots of duplicated "rgba(247, 249, 252, 0.05)" values in the theme, but not today... +export const stripePaymentFormFieldBackgroundDark = 'rgba(247, 249, 252, 0.05)'; + export const opaqueLightModeBackground = { light: 'rgba(255, 255, 255, 0.70)', dark: 'rgba(247, 249, 252, 0.05)', diff --git a/src/gql-types/graphql.ts b/src/gql-types/graphql.ts index b0db5e452b..1dca4e6c48 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -53,6 +53,21 @@ export type Scalars = { Url: { input: any; output: any; } }; +export type AwsPrivateLink = { + __typename?: 'AWSPrivateLink'; + azIds: Array; + region: Scalars['String']['output']; + serviceName: Scalars['String']['output']; + serviceRegion?: Maybe; +}; + +export type AwsPrivateLinkInput = { + azIds: Array; + region: Scalars['String']['input']; + serviceName: Scalars['String']['input']; + serviceRegion?: InputMaybe; +}; + /** Status of the abandonment evaluation for a task. */ export type AbandonStatus = { __typename?: 'AbandonStatus'; @@ -305,6 +320,21 @@ export type AutoDiscoverStatus = { pendingPublish?: Maybe; }; +export type AzurePrivateLink = { + __typename?: 'AzurePrivateLink'; + dnsName?: Maybe; + location: Scalars['String']['output']; + resourceType?: Maybe; + serviceName: Scalars['String']['output']; +}; + +export type AzurePrivateLinkInput = { + dnsName?: InputMaybe; + location: Scalars['String']['input']; + resourceType?: InputMaybe; + serviceName: Scalars['String']['input']; +}; + export type BillingPaymentMethodPayload = { __typename?: 'BillingPaymentMethodPayload'; paymentMethods: Array; @@ -332,7 +362,9 @@ export type CapabilityBit = | 'DeleteGrant' | 'JournalAppend' | 'JournalRead' - | 'SpecEdit'; + | 'ModifyDataPlanePrivateNetworking' + | 'SpecEdit' + | 'ViewDataPlanePrivateNetworking'; export type CardPaymentMethodDetails = { __typename?: 'CardPaymentMethodDetails'; @@ -540,22 +572,46 @@ export type DataPlane = { __typename?: 'DataPlane'; /** AWS IAM user ARN for this data-plane. */ awsIamUserArn?: Maybe; + /** + * AWS PrivateLink endpoint provisioning results, opaque JSON exported by + * the data-plane controller. Empty when no AWS endpoints are provisioned, + * or when the caller lacks `ViewDataPlanePrivateNetworking`. + */ + awsLinkEndpoints: Array; /** Azure application client ID for this data-plane. */ azureApplicationClientId?: Maybe; /** Azure application name for this data-plane. */ azureApplicationName?: Maybe; + /** + * Azure Private Link endpoint provisioning results, opaque JSON. Empty when + * the caller lacks `ViewDataPlanePrivateNetworking`. + */ + azureLinkEndpoints: Array; /** CIDR blocks for this data-plane. */ cidrBlocks: Array; /** Cloud provider where this data-plane is hosted. */ cloudProvider: DataPlaneCloudProvider; /** Fully-qualified domain name of this data-plane. */ fqdn: Scalars['String']['output']; + /** + * GCP Private Service Connect endpoint provisioning results, opaque JSON. + * Empty when the caller lacks `ViewDataPlanePrivateNetworking`. + */ + gcpPscEndpoints: Array; /** GCP service account email for this data-plane. */ gcpServiceAccountEmail?: Maybe; /** Whether this is a public data-plane. */ isPublic: Scalars['Boolean']['output']; /** Name of this data-plane under the catalog namespace. */ name: Scalars['String']['output']; + /** + * Configured private link endpoints for this data-plane. Replacing this + * list (via `updateDataPlanePrivateLinks`) triggers reconvergence by the + * data-plane controller on its next poll. Returns an empty list to + * callers that lack the `ViewDataPlanePrivateNetworking` capability on + * this data plane. + */ + privateLinks: Array; /** Address of reactors within the data-plane. */ reactorAddress: Scalars['String']['output']; /** @@ -629,6 +685,23 @@ export type FieldProvenance = { source?: Maybe; }; +export type GcpPrivateServiceConnect = { + __typename?: 'GCPPrivateServiceConnect'; + allPorts: Scalars['Boolean']['output']; + dnsRecordNames: Array; + dnsZoneName: Scalars['String']['output']; + region: Scalars['String']['output']; + serviceAttachment: Scalars['String']['output']; +}; + +export type GcpPrivateServiceConnectInput = { + allPorts?: Scalars['Boolean']['input']; + dnsRecordNames: Array; + dnsZoneName: Scalars['String']['input']; + region: Scalars['String']['input']; + serviceAttachment: Scalars['String']['input']; +}; + /** Status of the inferred schema */ export type InferredSchemaStatus = { __typename?: 'InferredSchemaStatus'; @@ -1012,6 +1085,19 @@ export type MutationRoot = { * the updated subscription. */ updateAlertSubscription: AlertSubscription; + /** + * Replaces the configured private link endpoints on a private data plane. + * + * The provided list overwrites the entire `private_links` column; partial + * updates are intentionally not supported. The data-plane controller + * converges to the new configuration on its next poll. Returns the desired + * private links state. The `*LinkEndpoints` provisioning results are not echoed here: + * they lag this write until the controller converges, so callers needing them re-query `dataPlanes`. + * + * Requires the `ModifyDataPlanePrivateNetworking` capability on the + * private data-plane name. + */ + updateDataPlanePrivateLinks: Array; /** * Update an existing storage mapping for the given catalog prefix. * @@ -1104,6 +1190,12 @@ export type MutationRootUpdateAlertSubscriptionArgs = { }; +export type MutationRootUpdateDataPlanePrivateLinksArgs = { + dataPlaneName: Scalars['String']['input']; + privateLinks: Array; +}; + + export type MutationRootUpdateStorageMappingArgs = { catalogPrefix: Scalars['Prefix']['input']; detail?: InputMaybe; @@ -1195,6 +1287,22 @@ export type PrefixesBy = { minCapability: Capability; }; +/** + * Private link configuration for a customer-owned data plane: AWS + * PrivateLink, Azure Private Link, or GCP Private Service Connect. + */ +export type PrivateLink = AwsPrivateLink | AzurePrivateLink | GcpPrivateServiceConnect; + +/** + * Private link configuration for a customer-owned data plane: AWS + * PrivateLink, Azure Private Link, or GCP Private Service Connect. + */ +export type PrivateLinkInput = { + aws?: InputMaybe; + azure?: InputMaybe; + gcp?: InputMaybe; +}; + /** Filter connectors by their protocol (capture or materialization). */ export type ProtocolFilter = { /** Match connectors that have at least one version with this protocol. */ diff --git a/src/gql-types/schema.graphql b/src/gql-types/schema.graphql index 6c46c564ff..bf2a418ac7 100644 --- a/src/gql-types/schema.graphql +++ b/src/gql-types/schema.graphql @@ -3,6 +3,20 @@ schema { mutation: MutationRoot } +type AWSPrivateLink { + azIds: [String!]! + region: String! + serviceName: String! + serviceRegion: String +} + +input AWSPrivateLinkInput { + azIds: [String!]! + region: String! + serviceName: String! + serviceRegion: String +} + """Status of the abandonment evaluation for a task.""" type AbandonStatus { """When this spec was last checked for abandonment""" @@ -287,6 +301,20 @@ type AutoDiscoverStatus { pendingPublish: AutoDiscoverOutcome } +type AzurePrivateLink { + dnsName: String + location: String! + resourceType: String + serviceName: String! +} + +input AzurePrivateLinkInput { + dnsName: String + location: String! + resourceType: String + serviceName: String! +} + type BillingPaymentMethodPayload { paymentMethods: [PaymentMethod!]! primaryPaymentMethod: PaymentMethod @@ -319,7 +347,9 @@ enum CapabilityBit { DeleteGrant JournalAppend JournalRead + ModifyDataPlanePrivateNetworking SpecEdit + ViewDataPlanePrivateNetworking } type CardPaymentMethodDetails { @@ -562,12 +592,25 @@ type DataPlane { """AWS IAM user ARN for this data-plane.""" awsIamUserArn: String + """ + AWS PrivateLink endpoint provisioning results, opaque JSON exported by + the data-plane controller. Empty when no AWS endpoints are provisioned, + or when the caller lacks `ViewDataPlanePrivateNetworking`. + """ + awsLinkEndpoints: [JSON!]! + """Azure application client ID for this data-plane.""" azureApplicationClientId: String """Azure application name for this data-plane.""" azureApplicationName: String + """ + Azure Private Link endpoint provisioning results, opaque JSON. Empty when + the caller lacks `ViewDataPlanePrivateNetworking`. + """ + azureLinkEndpoints: [JSON!]! + """CIDR blocks for this data-plane.""" cidrBlocks: [String!]! @@ -577,6 +620,12 @@ type DataPlane { """Fully-qualified domain name of this data-plane.""" fqdn: String! + """ + GCP Private Service Connect endpoint provisioning results, opaque JSON. + Empty when the caller lacks `ViewDataPlanePrivateNetworking`. + """ + gcpPscEndpoints: [JSON!]! + """GCP service account email for this data-plane.""" gcpServiceAccountEmail: String @@ -586,6 +635,15 @@ type DataPlane { """Name of this data-plane under the catalog namespace.""" name: String! + """ + Configured private link endpoints for this data-plane. Replacing this + list (via `updateDataPlanePrivateLinks`) triggers reconvergence by the + data-plane controller on its next poll. Returns an empty list to + callers that lack the `ViewDataPlanePrivateNetworking` capability on + this data plane. + """ + privateLinks: [PrivateLink!]! + """Address of reactors within the data-plane.""" reactorAddress: String! @@ -672,6 +730,22 @@ type FieldProvenance { source: String } +type GCPPrivateServiceConnect { + allPorts: Boolean! + dnsRecordNames: [String!]! + dnsZoneName: String! + region: String! + serviceAttachment: String! +} + +input GCPPrivateServiceConnectInput { + allPorts: Boolean! = false + dnsRecordNames: [String!]! + dnsZoneName: String! + region: String! + serviceAttachment: String! +} + scalar Id """Status of the inferred schema""" @@ -1044,6 +1118,20 @@ type MutationRoot { """ updateAlertSubscription(alertTypes: [AlertType!], detail: String, email: String!, prefix: Prefix!): AlertSubscription! + """ + Replaces the configured private link endpoints on a private data plane. + + The provided list overwrites the entire `private_links` column; partial + updates are intentionally not supported. The data-plane controller + converges to the new configuration on its next poll. Returns the desired + private links state. The `*LinkEndpoints` provisioning results are not echoed here: + they lag this write until the controller converges, so callers needing them re-query `dataPlanes`. + + Requires the `ModifyDataPlanePrivateNetworking` capability on the + private data-plane name. + """ + updateDataPlanePrivateLinks(dataPlaneName: String!, privateLinks: [PrivateLinkInput!]!): [PrivateLink!]! + """ Update an existing storage mapping for the given catalog prefix. @@ -1160,6 +1248,22 @@ input PrefixesBy { minCapability: Capability! } +""" +Private link configuration for a customer-owned data plane: AWS +PrivateLink, Azure Private Link, or GCP Private Service Connect. +""" +union PrivateLink = AWSPrivateLink | AzurePrivateLink | GCPPrivateServiceConnect + +""" +Private link configuration for a customer-owned data plane: AWS +PrivateLink, Azure Private Link, or GCP Private Service Connect. +""" +input PrivateLinkInput { + aws: AWSPrivateLinkInput + azure: AzurePrivateLinkInput + gcp: GCPPrivateServiceConnectInput +} + """Filter connectors by their protocol (capture or materialization).""" input ProtocolFilter { """Match connectors that have at least one version with this protocol.""" From 92b5da50fe59e20d5d0fc43d48f20e4fef63f1d1 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 23:23:25 -0400 Subject: [PATCH 02/16] Refresh token UI to use GQL (#1990) * Migrate refresh token UI from PostgREST to GraphQL * rework refresh token UI * clean up * Adopt gql updates * remove validFor from refresh token query - not exposed anymore * Regenerate gql-types against current schema Resyncs the generated GraphQL types with the live schema after rebasing onto main. Drops the service-account schema (ApiKeyInfo, ServiceAccount, createServiceAccount, revokeApiKey, serviceAccounts, the ManageServiceAccounts capability bit, TenantFilter) that an earlier codegen run had incidentally captured, and removes validFor from the RefreshTokenInfo output type and the RefreshTokens query, which the server no longer exposes. * Clean up * intl strings * address review feedback on refresh token table - Gate create dialog dismissal on the in-flight create state so backdrop/Escape can't close mid-request and orphan the new token's secret - Realign table headers with their cells: add a Uses header and move Status over the expired column - Suppress the empty-state message on load error so it no longer renders beneath the error banner - Recover from an empty non-first page with a useEffect (mirrors AccessLinksTable) instead of the row-level page-back heuristic, and drop the now-unused onRevoked prop; revoke marks a token expired rather than removing it, so a revoke never empties the page --- src/api/gql/refreshTokens.ts | 89 +++++++ src/api/tokens.ts | 81 ------ .../Api/RefreshToken/ConfigureTokenButton.tsx | 61 ----- .../admin/Api/RefreshToken/CreateDialog.tsx | 189 ++++++++++++++ .../Api/RefreshToken/Dialog/Description.tsx | 38 --- .../admin/Api/RefreshToken/Dialog/Error.tsx | 16 -- .../RefreshToken/Dialog/GenerateButton.tsx | 93 ------- .../admin/Api/RefreshToken/Dialog/Title.tsx | 54 ---- .../admin/Api/RefreshToken/Dialog/Token.tsx | 25 -- .../admin/Api/RefreshToken/RevokeDialog.tsx | 101 +++++++ .../admin/Api/RefreshToken/Store/create.ts | 82 ------ .../admin/Api/RefreshToken/Store/types.ts | 17 -- .../admin/Api/RefreshToken/Table.tsx | 246 ++++++++++++++++++ .../admin/Api/RefreshToken/index.tsx | 8 +- src/components/admin/Api/index.tsx | 2 +- src/components/tables/RefreshTokens/Rows.tsx | 52 ---- src/components/tables/RefreshTokens/index.tsx | 91 ------- .../cells/refreshTokens/RevokeToken.tsx | 99 ------- src/context/URQL.tsx | 7 + src/context/Zustand/invariableStores.ts | 3 - src/gql-types/gql.ts | 18 ++ src/gql-types/graphql.ts | 95 +++++++ src/gql-types/schema.graphql | 60 +++++ src/lang/en-US/AdminPage.ts | 26 +- src/lang/en-US/EntityTable.ts | 1 - src/services/supabase.ts | 2 - src/stores/Tables/hooks.ts | 1 - src/stores/names.ts | 1 - src/types/index.ts | 5 - 29 files changed, 826 insertions(+), 737 deletions(-) create mode 100644 src/api/gql/refreshTokens.ts delete mode 100644 src/api/tokens.ts delete mode 100644 src/components/admin/Api/RefreshToken/ConfigureTokenButton.tsx create mode 100644 src/components/admin/Api/RefreshToken/CreateDialog.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/Description.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/Error.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/GenerateButton.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/Title.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/Token.tsx create mode 100644 src/components/admin/Api/RefreshToken/RevokeDialog.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Store/create.ts delete mode 100644 src/components/admin/Api/RefreshToken/Store/types.ts create mode 100644 src/components/admin/Api/RefreshToken/Table.tsx delete mode 100644 src/components/tables/RefreshTokens/Rows.tsx delete mode 100644 src/components/tables/RefreshTokens/index.tsx delete mode 100644 src/components/tables/cells/refreshTokens/RevokeToken.tsx diff --git a/src/api/gql/refreshTokens.ts b/src/api/gql/refreshTokens.ts new file mode 100644 index 0000000000..9bb3782817 --- /dev/null +++ b/src/api/gql/refreshTokens.ts @@ -0,0 +1,89 @@ +import type { RefreshTokenInfo } from 'src/gql-types/graphql'; + +import { useMemo } from 'react'; + +import { useMutation, useQuery } from 'urql'; + +import { graphql } from 'src/gql-types'; + +const DEFAULT_TOKENS: RefreshTokenInfo[] = []; + +const REFRESH_TOKENS_PAGE_SIZE = 10; + +const REFRESH_TOKENS_QUERY = graphql(` + query RefreshTokens($first: Int, $after: String) { + refreshTokens(first: $first, after: $after) { + edges { + node { + id + detail + createdAt + uses + expired + } + cursor + } + pageInfo { + ...PageInfoFields + } + } + } +`); + +export function useRefreshTokens(afterCursor?: string) { + const [{ fetching, data, error }] = useQuery({ + query: REFRESH_TOKENS_QUERY, + variables: { + first: REFRESH_TOKENS_PAGE_SIZE, + after: afterCursor, + }, + }); + + const refreshTokens = useMemo( + () => + data?.refreshTokens?.edges?.map((edge) => edge.node) ?? + DEFAULT_TOKENS, + [data] + ); + + const pageInfo = data?.refreshTokens?.pageInfo ?? null; + + return { + refreshTokens, + fetching, + error, + pageInfo, + pageSize: REFRESH_TOKENS_PAGE_SIZE, + }; +} + +const CREATE_REFRESH_TOKEN = graphql(` + mutation CreateRefreshToken( + $detail: String + $multiUse: Boolean! + $validFor: String! + ) { + createRefreshToken( + detail: $detail + multiUse: $multiUse + validFor: $validFor + ) { + id + secret + } + } +`); + +const REVOKE_REFRESH_TOKEN = graphql(` + mutation RevokeRefreshToken($id: Id!) { + revokeRefreshToken(id: $id) + } +`); + +export function useCreateRefreshToken() { + return useMutation(CREATE_REFRESH_TOKEN); +} + +export function useRevokeRefreshToken() { + return useMutation(REVOKE_REFRESH_TOKEN); +} diff --git a/src/api/tokens.ts b/src/api/tokens.ts deleted file mode 100644 index fdddc26633..0000000000 --- a/src/api/tokens.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { PostgrestSingleResponse } from '@supabase/postgrest-js'; -import type { SortingProps } from 'src/services/supabase'; -import type { RefreshTokenData } from 'src/types'; - -import { supabaseClient } from 'src/context/GlobalProviders'; -import { - defaultTableFilter, - handleFailure, - handleSuccess, - RPCS, - supabaseRetry, - TABLES, -} from 'src/services/supabase'; - -const createRefreshToken = async ( - multi_use: boolean, - valid_for: string, - detail?: string -) => { - return supabaseRetry>( - () => - supabaseClient - .rpc(RPCS.CREATE_REFRESH_TOKEN, { - multi_use, - valid_for, - detail, - }) - .single(), - 'createRefreshToken' - ); -}; - -// Nullifying the interval for which a refresh token is valid is a means -// to invalidate the token while preserving the row in the database table. -export const INVALID_TOKEN_INTERVAL = '0 seconds'; - -export interface RefreshTokenQuery { - created_at: string; - detail: string; - id: string; - multi_use: boolean; - uses: number; - valid_for: string; -} - -const getRefreshTokensForTable = ( - pagination: any, - searchQuery: any, - sorting: SortingProps[] -) => { - return defaultTableFilter( - supabaseClient - .from(TABLES.REFRESH_TOKENS) - .select('created_at,detail,id,multi_use,uses,valid_for', { - count: 'exact', - }) - .eq('multi_use', true) - .neq('valid_for', INVALID_TOKEN_INTERVAL), - ['detail'], - searchQuery, - sorting, - pagination - ); -}; - -const updateRefreshTokenValidity = (id: string, interval: string) => { - return supabaseRetry( - () => - supabaseClient - .from(TABLES.REFRESH_TOKENS) - .update({ valid_for: interval }) - .match({ id }), - 'updateRefreshTokenValidity' - ).then(handleSuccess, handleFailure); -}; - -export { - createRefreshToken, - getRefreshTokensForTable, - updateRefreshTokenValidity, -}; diff --git a/src/components/admin/Api/RefreshToken/ConfigureTokenButton.tsx b/src/components/admin/Api/RefreshToken/ConfigureTokenButton.tsx deleted file mode 100644 index e8fcca75f3..0000000000 --- a/src/components/admin/Api/RefreshToken/ConfigureTokenButton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react'; - -import { Button, Dialog, DialogContent, Grid } from '@mui/material'; - -import { FormattedMessage } from 'react-intl'; - -import RefreshTokenDescription from 'src/components/admin/Api/RefreshToken/Dialog/Description'; -import RefreshTokenError from 'src/components/admin/Api/RefreshToken/Dialog/Error'; -import GenerateButton from 'src/components/admin/Api/RefreshToken/Dialog/GenerateButton'; -import RefreshTokenTitle from 'src/components/admin/Api/RefreshToken/Dialog/Title'; -import CopyRefreshToken from 'src/components/admin/Api/RefreshToken/Dialog/Token'; - -const TITLE_ID = 'create-refresh-tokens-title'; - -function ConfigureRefreshTokenButton() { - const [open, setOpen] = useState(false); - - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export default ConfigureRefreshTokenButton; diff --git a/src/components/admin/Api/RefreshToken/CreateDialog.tsx b/src/components/admin/Api/RefreshToken/CreateDialog.tsx new file mode 100644 index 0000000000..7f02ed03c4 --- /dev/null +++ b/src/components/admin/Api/RefreshToken/CreateDialog.tsx @@ -0,0 +1,189 @@ +import type { ErrorDetails } from 'src/components/shared/Error/types'; + +import { useState } from 'react'; + +import { + Button, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Stack, + TextField, + Typography, + useTheme, +} from '@mui/material'; + +import { Xmark } from 'iconoir-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { useCreateRefreshToken } from 'src/api/gql/refreshTokens'; +import SingleLineCode from 'src/components/content/SingleLineCode'; +import AlertBox from 'src/components/shared/AlertBox'; +import Error from 'src/components/shared/Error'; +import { hasLength } from 'src/utils/misc-utils'; + +const TOKEN_VALIDITY = 'P1Y'; + +// The shared Error component treats an error's `message` as an i18n key unless +// the object looks like a Supabase or GraphQL error (it carries a `code` or a +// `networkError`). This client-side error carries neither, so the message is +// resolved from the language files. +const TOKEN_DISPLAY_ERROR: ErrorDetails = { + message: 'admin.cli_api.refreshToken.dialog.alert.tokenEncodingFailed', +}; + +interface Props { + open: boolean; + onClose: () => void; + onCreated: () => void; +} + +export function CreateRefreshTokenDialog({ open, onClose, onCreated }: Props) { + const intl = useIntl(); + const theme = useTheme(); + + const [label, setLabel] = useState(''); + const [token, setToken] = useState(''); + const [serverError, setServerError] = useState(null); + + const [{ fetching: generating }, createRefreshToken] = + useCreateRefreshToken(); + + const resetDialog = () => { + setLabel(''); + setToken(''); + setServerError(null); + }; + + const generateToken = async (event: React.FormEvent) => { + event.preventDefault(); + + setServerError(null); + + const result = await createRefreshToken({ + multiUse: true, + validFor: TOKEN_VALIDITY, + detail: label, + }); + + if (result.error || !result.data?.createRefreshToken) { + setServerError(result.error ?? TOKEN_DISPLAY_ERROR); + + return; + } + + const { id, secret } = result.data.createRefreshToken; + + // The refresh token ID and secret are needed by Flow, therefore it was decided + // to base64 encode the data returned in the response and present that as the + // one-time secret presented to the user. + const encodedToken = Buffer.from( + JSON.stringify({ id, secret }) + ).toString('base64'); + + if (!hasLength(encodedToken)) { + setServerError(TOKEN_DISPLAY_ERROR); + + return; + } + + setToken(encodedToken); + onCreated(); + }; + + return ( + + + + + + + + + + + + + + {serverError ? ( + + ) : null} + + {token ? ( + + + + + + + + ) : ( + + + setLabel(event.target.value) + } + required + size="small" + sx={{ flex: 1 }} + value={label} + variant="outlined" + slotProps={{ + input: { + sx: { borderRadius: 3 }, + }, + }} + /> + + + + )} + + + + ); +} diff --git a/src/components/admin/Api/RefreshToken/Dialog/Description.tsx b/src/components/admin/Api/RefreshToken/Dialog/Description.tsx deleted file mode 100644 index 04534c63ff..0000000000 --- a/src/components/admin/Api/RefreshToken/Dialog/Description.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { TextField } from '@mui/material'; - -import { useIntl } from 'react-intl'; - -import { useRefreshTokenStore } from 'src/components/admin/Api/RefreshToken/Store/create'; - -function RefreshTokenDescription() { - const intl = useIntl(); - - const description = useRefreshTokenStore((state) => state.description); - const updateDescription = useRefreshTokenStore( - (state) => state.updateDescription - ); - - return ( - updateDescription(event.target.value)} - required - size="small" - sx={{ flexGrow: 1 }} - value={description} - variant="outlined" - slotProps={{ - input: { - sx: { borderRadius: 3 }, - }, - }} - /> - ); -} - -export default RefreshTokenDescription; diff --git a/src/components/admin/Api/RefreshToken/Dialog/Error.tsx b/src/components/admin/Api/RefreshToken/Dialog/Error.tsx deleted file mode 100644 index d5719e89c1..0000000000 --- a/src/components/admin/Api/RefreshToken/Dialog/Error.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Grid } from '@mui/material'; - -import { useRefreshTokenStore } from 'src/components/admin/Api/RefreshToken/Store/create'; -import Error from 'src/components/shared/Error'; - -function RefreshTokenError() { - const serverError = useRefreshTokenStore((state) => state.serverError); - - return serverError ? ( - - - - ) : null; -} - -export default RefreshTokenError; diff --git a/src/components/admin/Api/RefreshToken/Dialog/GenerateButton.tsx b/src/components/admin/Api/RefreshToken/Dialog/GenerateButton.tsx deleted file mode 100644 index 1871a05772..0000000000 --- a/src/components/admin/Api/RefreshToken/Dialog/GenerateButton.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import type { SelectableTableStore } from 'src/stores/Tables/Store'; - -import { Button } from '@mui/material'; - -import { isEmpty } from 'lodash'; -import { useIntl } from 'react-intl'; - -import { createRefreshToken } from 'src/api/tokens'; -import { useRefreshTokenStore } from 'src/components/admin/Api/RefreshToken/Store/create'; -import { useZustandStore } from 'src/context/Zustand/provider'; -import { SelectTableStoreNames } from 'src/stores/names'; -import { selectableTableStoreSelectors } from 'src/stores/Tables/Store'; -import { hasLength } from 'src/utils/misc-utils'; - -const TOKEN_VALIDITY = '1 year'; - -function GenerateButton() { - const intl = useIntl(); - - const hydrate = useZustandStore< - SelectableTableStore, - SelectableTableStore['hydrate'] - >( - SelectTableStoreNames.REFRESH_TOKENS, - selectableTableStoreSelectors.query.hydrate - ); - - const description = useRefreshTokenStore((state) => state.description); - const saving = useRefreshTokenStore((state) => state.saving); - const setSaving = useRefreshTokenStore((state) => state.setSaving); - const setToken = useRefreshTokenStore((state) => state.setToken); - const setServerError = useRefreshTokenStore( - (state) => state.setServerError - ); - - const onClick = async (event: React.MouseEvent) => { - event.preventDefault(); - - setServerError(null); - setSaving(true); - - const response = await createRefreshToken( - true, - TOKEN_VALIDITY, - description - ); - - setSaving(false); - - if (response.error || isEmpty(response.data)) { - setServerError(response.error); - - return; - } - - hydrate(); - - // The refresh token ID and secret are needed by Flow, therefore it was decided - // to base64 encode the data returned in the response and present that as the - // one-time secret presented to the user. - const token = Buffer.from(JSON.stringify(response.data)).toString( - 'base64' - ); - - if (!hasLength(token)) { - setServerError( - intl.formatMessage({ - id: 'admin.cli_api.refreshToken.dialog.alert.tokenEncodingFailed', - }) - ); - - return; - } - - setToken(token); - }; - - return ( - - ); -} - -export default GenerateButton; diff --git a/src/components/admin/Api/RefreshToken/Dialog/Title.tsx b/src/components/admin/Api/RefreshToken/Dialog/Title.tsx deleted file mode 100644 index 291ca11c0e..0000000000 --- a/src/components/admin/Api/RefreshToken/Dialog/Title.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react'; - -import { DialogTitle, IconButton, Typography, useTheme } from '@mui/material'; - -import { Xmark } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; - -import { useRefreshTokenStore } from 'src/components/admin/Api/RefreshToken/Store/create'; - -interface Props { - setOpen: Dispatch>; -} - -function RefreshTokenTitle({ setOpen }: Props) { - const intl = useIntl(); - const theme = useTheme(); - - const saving = useRefreshTokenStore((state) => state.saving); - const resetState = useRefreshTokenStore((state) => state.resetState); - - const closeDialog = (event: React.MouseEvent) => { - event.preventDefault(); - - setOpen(false); - resetState(); - }; - - return ( - - - - - - - - - - ); -} - -export default RefreshTokenTitle; diff --git a/src/components/admin/Api/RefreshToken/Dialog/Token.tsx b/src/components/admin/Api/RefreshToken/Dialog/Token.tsx deleted file mode 100644 index 8a0e3b27e4..0000000000 --- a/src/components/admin/Api/RefreshToken/Dialog/Token.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Grid, Typography } from '@mui/material'; - -import { FormattedMessage } from 'react-intl'; - -import { useRefreshTokenStore } from 'src/components/admin/Api/RefreshToken/Store/create'; -import SingleLineCode from 'src/components/content/SingleLineCode'; -import AlertBox from 'src/components/shared/AlertBox'; - -function CopyRefreshToken() { - const token = useRefreshTokenStore((state) => state.token); - - return token ? ( - - - - - - - - - - ) : null; -} - -export default CopyRefreshToken; diff --git a/src/components/admin/Api/RefreshToken/RevokeDialog.tsx b/src/components/admin/Api/RefreshToken/RevokeDialog.tsx new file mode 100644 index 0000000000..e52d592351 --- /dev/null +++ b/src/components/admin/Api/RefreshToken/RevokeDialog.tsx @@ -0,0 +1,101 @@ +import type { CombinedError } from 'urql'; + +import { useState } from 'react'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; + +import { FormattedMessage } from 'react-intl'; + +import { useRevokeRefreshToken } from 'src/api/gql/refreshTokens'; +import Error from 'src/components/shared/Error'; + +interface Props { + open: boolean; + onClose: () => void; + id: string; + detail?: string | null; +} + +export function RevokeDialog({ open, onClose, id, detail }: Props) { + const [, revokeRefreshToken] = useRevokeRefreshToken(); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const revokeToken = async () => { + setSaving(true); + setError(null); + + const result = await revokeRefreshToken({ id }); + + if (result.error) { + setError(result.error); + setSaving(false); + + return; + } + + setSaving(false); + onClose(); + }; + + return ( + setError(null), + }, + }} + maxWidth="xs" + fullWidth + > + + + + + + {error ? ( + + ) : null} + + {detail ? ( + + ) : ( + + )} + + + + + + + + + + + + ); +} diff --git a/src/components/admin/Api/RefreshToken/Store/create.ts b/src/components/admin/Api/RefreshToken/Store/create.ts deleted file mode 100644 index 61902675ce..0000000000 --- a/src/components/admin/Api/RefreshToken/Store/create.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { RefreshTokenState } from 'src/components/admin/Api/RefreshToken/Store/types'; -import type { StoreApi } from 'zustand'; -import type { NamedSet } from 'zustand/middleware'; - -import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; - -import produce from 'immer'; - -import { BASE_ERROR } from 'src/services/supabase'; -import { devtoolsOptions } from 'src/utils/store-utils'; - -const getInitialStateData = (): Pick< - RefreshTokenState, - 'description' | 'saving' | 'serverError' | 'token' -> => ({ - description: '', - saving: false, - serverError: null, - token: '', -}); - -const getInitialState = ( - set: NamedSet, - _get: StoreApi['getState'] -): RefreshTokenState => ({ - ...getInitialStateData(), - - resetState: () => { - set(getInitialStateData(), false, 'State reset'); - }, - - setSaving: (value) => { - set( - produce((state: RefreshTokenState) => { - state.saving = value; - }), - false, - 'Saving set' - ); - }, - - setServerError: (value) => { - set( - produce((state: RefreshTokenState) => { - state.serverError = - typeof value === 'string' - ? { ...BASE_ERROR, message: value } - : value; - }), - false, - 'Server error set' - ); - }, - - setToken: (value) => { - set( - produce((state: RefreshTokenState) => { - state.token = value; - }), - false, - 'Refresh token set' - ); - }, - - updateDescription: (value) => { - set( - produce((state: RefreshTokenState) => { - state.description = value; - }), - false, - 'Token description updated' - ); - }, -}); - -export const useRefreshTokenStore = create()( - devtools( - (set, get) => getInitialState(set, get), - devtoolsOptions('refresh-tokens') - ) -); diff --git a/src/components/admin/Api/RefreshToken/Store/types.ts b/src/components/admin/Api/RefreshToken/Store/types.ts deleted file mode 100644 index 75216c7f43..0000000000 --- a/src/components/admin/Api/RefreshToken/Store/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PostgrestError } from '@supabase/postgrest-js'; - -export interface RefreshTokenState { - token: string; - setToken: (value: string) => void; - - description: string; - updateDescription: (value: string) => void; - - saving: boolean; - setSaving: (value: boolean) => void; - - serverError: PostgrestError | null; - setServerError: (value: PostgrestError | string | null) => void; - - resetState: () => void; -} diff --git a/src/components/admin/Api/RefreshToken/Table.tsx b/src/components/admin/Api/RefreshToken/Table.tsx new file mode 100644 index 0000000000..6e65f3708a --- /dev/null +++ b/src/components/admin/Api/RefreshToken/Table.tsx @@ -0,0 +1,246 @@ +import type { RefreshTokenInfo } from 'src/gql-types/graphql'; + +import { useEffect, useState } from 'react'; + +import { + Box, + Button, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHead, + TablePagination, + TableRow, + Typography, +} from '@mui/material'; + +import { Trash } from 'iconoir-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { useRefreshTokens } from 'src/api/gql/refreshTokens'; +import { CreateRefreshTokenDialog } from 'src/components/admin/Api/RefreshToken/CreateDialog'; +import { RevokeDialog } from 'src/components/admin/Api/RefreshToken/RevokeDialog'; +import TimeStamp from 'src/components/tables/cells/TimeStamp'; +import { useCursorPagination } from 'src/hooks/useCursorPagination'; + +interface RowProps { + row: Pick< + RefreshTokenInfo, + 'id' | 'detail' | 'createdAt' | 'uses' | 'expired' + >; +} + +function Row({ row }: RowProps) { + const intl = useIntl(); + + const [revokeOpen, setRevokeOpen] = useState(false); + + return ( + + + + + {row.detail} + + + + {intl.formatMessage( + { id: 'admin.cli_api.refreshToken.table.label.uses' }, + { count: row.uses } + )} + + + + {row.expired ? ( + + + + ) : null} + + + + setRevokeOpen(true)} + size="small" + sx={{ opacity: 0, transition: 'opacity 100ms ease-in-out' }} + > + + + + setRevokeOpen(false)} + id={row.id} + detail={row.detail} + /> + + + ); +} + +export function RefreshTokenTable() { + const { currentPage, cursor, goToPage, onPageChange } = + useCursorPagination(); + + const { refreshTokens, fetching, error, pageInfo, pageSize } = + useRefreshTokens(cursor); + + const [dialogOpen, setDialogOpen] = useState(false); + + const handlePageChange = (_event: any, page: number) => { + onPageChange(_event, page, pageInfo?.endCursor); + }; + + // Revoking the last token on a page empties it, since the list query + // excludes revoked tokens (a concurrent revoke elsewhere can do the same). + // Step back so the user lands on a populated page instead of being + // stranded on a blank one with no Previous control. Mirrors the + // AccessLinksTable recovery. + useEffect(() => { + if ( + !fetching && + !error && + refreshTokens.length === 0 && + currentPage > 0 + ) { + goToPage(currentPage - 1); + } + }, [fetching, error, refreshTokens.length, currentPage, goToPage]); + + return ( + + + + + setDialogOpen(false)} + onCreated={() => goToPage(0)} + /> + + + {error ? ( + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + {fetching && refreshTokens.length === 0 ? ( + + + + + + ) : refreshTokens.length === 0 && !error ? ( + + + + + + setDialogOpen(true)} + sx={{ + 'cursor': 'pointer', + '&:hover': { + textDecoration: 'underline', + }, + }} + > + + + + + ) : ( + refreshTokens.map((row) => ( + + )) + )} + + + {pageInfo && refreshTokens.length > 0 ? ( + + + { + const to = + from + refreshTokens.length - 1; + return `${from}–${to}`; + }} + slotProps={{ + actions: { + previousButton: { + disabled: + !pageInfo.hasPreviousPage, + }, + nextButton: { + disabled: !pageInfo.hasNextPage, + }, + }, + }} + /> + + + ) : null} +
+
+
+ ); +} diff --git a/src/components/admin/Api/RefreshToken/index.tsx b/src/components/admin/Api/RefreshToken/index.tsx index 33ff8fdd20..f45311ed73 100644 --- a/src/components/admin/Api/RefreshToken/index.tsx +++ b/src/components/admin/Api/RefreshToken/index.tsx @@ -2,9 +2,9 @@ import { Box, Stack, Typography } from '@mui/material'; import { FormattedMessage } from 'react-intl'; -import RefreshTokenTable from 'src/components/tables/RefreshTokens'; +import { RefreshTokenTable } from 'src/components/admin/Api/RefreshToken/Table'; -function RefreshToken() { +export function RefreshToken() { return ( @@ -15,7 +15,7 @@ function RefreshToken() { fontWeight: '400', }} > - + @@ -27,5 +27,3 @@ function RefreshToken() { ); } - -export default RefreshToken; diff --git a/src/components/admin/Api/index.tsx b/src/components/admin/Api/index.tsx index cb5e642b75..9e9e93fe6c 100644 --- a/src/components/admin/Api/index.tsx +++ b/src/components/admin/Api/index.tsx @@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl'; import { authenticatedRoutes } from 'src/app/routes'; import AccessToken from 'src/components/admin/Api/AccessToken'; -import RefreshToken from 'src/components/admin/Api/RefreshToken'; +import { RefreshToken } from 'src/components/admin/Api/RefreshToken'; import AdminTabs from 'src/components/admin/Tabs'; import usePageTitle from 'src/hooks/usePageTitle'; diff --git a/src/components/tables/RefreshTokens/Rows.tsx b/src/components/tables/RefreshTokens/Rows.tsx deleted file mode 100644 index 46db58841a..0000000000 --- a/src/components/tables/RefreshTokens/Rows.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { RefreshTokenQuery } from 'src/api/tokens'; - -import { TableCell, TableRow, Typography, useTheme } from '@mui/material'; - -import { FormattedMessage } from 'react-intl'; - -import RevokeTokenButton from 'src/components/tables/cells/refreshTokens/RevokeToken'; -import TimeStamp from 'src/components/tables/cells/TimeStamp'; -import { getEntityTableRowSx } from 'src/context/Theme'; - -interface RowsProps { - data: RefreshTokenQuery[]; -} - -interface RowProps { - row: RefreshTokenQuery; -} - -function Row({ row }: RowProps) { - const theme = useTheme(); - - return ( - - - - - {row.detail} - - - - - - - - - ); -} - -function Rows({ data }: RowsProps) { - return ( - <> - {data.map((row) => ( - - ))} - - ); -} - -export default Rows; diff --git a/src/components/tables/RefreshTokens/index.tsx b/src/components/tables/RefreshTokens/index.tsx deleted file mode 100644 index a81dac49e5..0000000000 --- a/src/components/tables/RefreshTokens/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { TableColumns } from 'src/types'; - -import { useMemo } from 'react'; - -import { getRefreshTokensForTable } from 'src/api/tokens'; -import ConfigureRefreshTokenButton from 'src/components/admin/Api/RefreshToken/ConfigureTokenButton'; -import EntityTable from 'src/components/tables/EntityTable'; -import Rows from 'src/components/tables/RefreshTokens/Rows'; -import { SelectTableStoreNames } from 'src/stores/names'; -import { TablePrefixes, useTableState } from 'src/stores/Tables/hooks'; -import TableHydrator from 'src/stores/Tables/Hydrator'; - -const columns: TableColumns[] = [ - { - field: 'created_at', - headerIntlKey: 'entityTable.data.created', - }, - { - field: 'detail', - headerIntlKey: 'entityTable.data.description', - }, - { - field: 'uses', - headerIntlKey: 'entityTable.data.status', - }, - { - field: null, - width: 125, - }, -]; - -const selectableTableStoreName = SelectTableStoreNames.REFRESH_TOKENS; - -function RefreshTokenTable() { - const { - pagination, - setPagination, - rowsPerPage, - setRowsPerPage, - searchQuery, - setSearchQuery, - sortDirection, - setSortDirection, - columnToSort, - setColumnToSort, - } = useTableState(TablePrefixes.refreshTokens, 'created_at', 'desc'); - - const query = useMemo(() => { - return getRefreshTokensForTable(pagination, searchQuery, [ - { - col: columnToSort, - direction: sortDirection, - }, - ]); - }, [columnToSort, pagination, searchQuery, sortDirection]); - - return ( - - } - rowsPerPage={rowsPerPage} - setRowsPerPage={setRowsPerPage} - pagination={pagination} - setPagination={setPagination} - searchQuery={searchQuery} - setSearchQuery={setSearchQuery} - sortDirection={sortDirection} - setSortDirection={setSortDirection} - columnToSort={columnToSort} - setColumnToSort={setColumnToSort} - header={null} - filterLabel="admin.cli_api.refreshToken.table.filterLabel" - selectableTableStoreName={selectableTableStoreName} - showToolbar - toolbar={} - /> - - ); -} - -export default RefreshTokenTable; diff --git a/src/components/tables/cells/refreshTokens/RevokeToken.tsx b/src/components/tables/cells/refreshTokens/RevokeToken.tsx deleted file mode 100644 index d0ae1e7b6b..0000000000 --- a/src/components/tables/cells/refreshTokens/RevokeToken.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { PostgrestError } from '@supabase/postgrest-js'; -import type { SelectableTableStore } from 'src/stores/Tables/Store'; - -import { useState } from 'react'; - -import { Button, Stack, TableCell, Tooltip, useTheme } from '@mui/material'; - -import { WarningCircle } from 'iconoir-react'; -import { useIntl } from 'react-intl'; - -import { - INVALID_TOKEN_INTERVAL, - updateRefreshTokenValidity, -} from 'src/api/tokens'; -import Error from 'src/components/shared/Error'; -import { sample_blue } from 'src/context/Theme'; -import { useZustandStore } from 'src/context/Zustand/provider'; -import { SelectTableStoreNames } from 'src/stores/names'; -import { selectableTableStoreSelectors } from 'src/stores/Tables/Store'; - -interface Props { - id: string; -} - -function RevokeTokenButton({ id }: Props) { - const intl = useIntl(); - const theme = useTheme(); - - const hydrate = useZustandStore< - SelectableTableStore, - SelectableTableStore['hydrate'] - >( - SelectTableStoreNames.REFRESH_TOKENS, - selectableTableStoreSelectors.query.hydrate - ); - - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const revokeToken = async (event: React.MouseEvent) => { - event.preventDefault(); - - setSaving(true); - setError(null); - - const response = await updateRefreshTokenValidity( - id, - INVALID_TOKEN_INTERVAL - ); - - if (response.error) { - setError(response.error); - setSaving(false); - - return; - } - - hydrate(); - setSaving(false); - }; - - return ( - - - - - {error ? ( - - } - > - - - ) : null} - - - ); -} - -export default RevokeTokenButton; diff --git a/src/context/URQL.tsx b/src/context/URQL.tsx index e2a67e7cdb..9974f78c38 100644 --- a/src/context/URQL.tsx +++ b/src/context/URQL.tsx @@ -60,6 +60,7 @@ function UrqlConfigProvider({ children }: BaseComponentProps) { InviteLink: (data) => null, LiveSpecRef: (_data) => null, PrefixRef: (_data) => null, + RefreshTokenInfo: (_data) => null, StorageMapping: (data) => null, DataPlane: (data) => null, }, @@ -80,6 +81,12 @@ function UrqlConfigProvider({ children }: BaseComponentProps) { updateAlertSubscription(_result, _args, cache) { invalidateQuery(cache, 'alertSubscriptions'); }, + createRefreshToken(_result, _args, cache) { + invalidateQuery(cache, 'refreshTokens'); + }, + revokeRefreshToken(_result, _args, cache) { + invalidateQuery(cache, 'refreshTokens'); + }, }, }, }), diff --git a/src/context/Zustand/invariableStores.ts b/src/context/Zustand/invariableStores.ts index 7ff7a2b623..021aedfb3f 100644 --- a/src/context/Zustand/invariableStores.ts +++ b/src/context/Zustand/invariableStores.ts @@ -81,9 +81,6 @@ const invariableStores = { [SelectTableStoreNames.MATERIALIZATION]: createSelectableTableStore( SelectTableStoreNames.MATERIALIZATION ), - [SelectTableStoreNames.REFRESH_TOKENS]: createSelectableTableStore( - SelectTableStoreNames.REFRESH_TOKENS - ), // Shard Detail Store [ShardDetailStoreNames.CAPTURE]: createShardDetailStore( diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index 27c65fbb9b..f3bf218a46 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -27,6 +27,9 @@ type Documents = { "\n mutation DeleteInviteLink($token: UUID!) {\n deleteInviteLink(token: $token)\n }\n": typeof types.DeleteInviteLinkDocument, "\n mutation RedeemInviteLink($token: UUID!) {\n redeemInviteLink(token: $token) {\n capability\n catalogPrefix\n }\n }\n": typeof types.RedeemInviteLinkDocument, "\n query LiveSpecsQuery($prefix: Prefix!, $after: String) {\n liveSpecs(by: { prefix: $prefix }, first: 100, after: $after) {\n edges {\n cursor\n node {\n catalogName\n liveSpec {\n catalogType\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.LiveSpecsQueryDocument, + "\n query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n uses\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": typeof types.RefreshTokensDocument, + "\n mutation CreateRefreshToken(\n $detail: String\n $multiUse: Boolean!\n $validFor: String!\n ) {\n createRefreshToken(\n detail: $detail\n multiUse: $multiUse\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n": typeof types.CreateRefreshTokenDocument, + "\n mutation RevokeRefreshToken($id: Id!) {\n revokeRefreshToken(id: $id)\n }\n": typeof types.RevokeRefreshTokenDocument, "\n mutation CreateStorageMapping(\n $catalogPrefix: Prefix!\n $spec: JSON!\n $detail: String\n ) {\n createStorageMapping(\n catalogPrefix: $catalogPrefix\n spec: $spec\n detail: $detail\n ) {\n catalogPrefix\n }\n }\n": typeof types.CreateStorageMappingDocument, "\n mutation UpdateStorageMapping(\n $catalogPrefix: Prefix!\n $spec: JSON!\n $detail: String\n ) {\n updateStorageMapping(\n catalogPrefix: $catalogPrefix\n spec: $spec\n detail: $detail\n ) {\n catalogPrefix\n republish\n }\n }\n": typeof types.UpdateStorageMappingDocument, "\n mutation TestConnectionHealth($catalogPrefix: Prefix!, $spec: JSON!) {\n testConnectionHealth(catalogPrefix: $catalogPrefix, spec: $spec) {\n results {\n fragmentStore\n dataPlaneName\n error\n }\n }\n }\n": typeof types.TestConnectionHealthDocument, @@ -52,6 +55,9 @@ const documents: Documents = { "\n mutation DeleteInviteLink($token: UUID!) {\n deleteInviteLink(token: $token)\n }\n": types.DeleteInviteLinkDocument, "\n mutation RedeemInviteLink($token: UUID!) {\n redeemInviteLink(token: $token) {\n capability\n catalogPrefix\n }\n }\n": types.RedeemInviteLinkDocument, "\n query LiveSpecsQuery($prefix: Prefix!, $after: String) {\n liveSpecs(by: { prefix: $prefix }, first: 100, after: $after) {\n edges {\n cursor\n node {\n catalogName\n liveSpec {\n catalogType\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.LiveSpecsQueryDocument, + "\n query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n uses\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": types.RefreshTokensDocument, + "\n mutation CreateRefreshToken(\n $detail: String\n $multiUse: Boolean!\n $validFor: String!\n ) {\n createRefreshToken(\n detail: $detail\n multiUse: $multiUse\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n": types.CreateRefreshTokenDocument, + "\n mutation RevokeRefreshToken($id: Id!) {\n revokeRefreshToken(id: $id)\n }\n": types.RevokeRefreshTokenDocument, "\n mutation CreateStorageMapping(\n $catalogPrefix: Prefix!\n $spec: JSON!\n $detail: String\n ) {\n createStorageMapping(\n catalogPrefix: $catalogPrefix\n spec: $spec\n detail: $detail\n ) {\n catalogPrefix\n }\n }\n": types.CreateStorageMappingDocument, "\n mutation UpdateStorageMapping(\n $catalogPrefix: Prefix!\n $spec: JSON!\n $detail: String\n ) {\n updateStorageMapping(\n catalogPrefix: $catalogPrefix\n spec: $spec\n detail: $detail\n ) {\n catalogPrefix\n republish\n }\n }\n": types.UpdateStorageMappingDocument, "\n mutation TestConnectionHealth($catalogPrefix: Prefix!, $spec: JSON!) {\n testConnectionHealth(catalogPrefix: $catalogPrefix, spec: $spec) {\n results {\n fragmentStore\n dataPlaneName\n error\n }\n }\n }\n": types.TestConnectionHealthDocument, @@ -130,6 +136,18 @@ export function graphql(source: "\n mutation RedeemInviteLink($token: UUID!) * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query LiveSpecsQuery($prefix: Prefix!, $after: String) {\n liveSpecs(by: { prefix: $prefix }, first: 100, after: $after) {\n edges {\n cursor\n node {\n catalogName\n liveSpec {\n catalogType\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query LiveSpecsQuery($prefix: Prefix!, $after: String) {\n liveSpecs(by: { prefix: $prefix }, first: 100, after: $after) {\n edges {\n cursor\n node {\n catalogName\n liveSpec {\n catalogType\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n uses\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"): (typeof documents)["\n query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n uses\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation CreateRefreshToken(\n $detail: String\n $multiUse: Boolean!\n $validFor: String!\n ) {\n createRefreshToken(\n detail: $detail\n multiUse: $multiUse\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n"): (typeof documents)["\n mutation CreateRefreshToken(\n $detail: String\n $multiUse: Boolean!\n $validFor: String!\n ) {\n createRefreshToken(\n detail: $detail\n multiUse: $multiUse\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation RevokeRefreshToken($id: Id!) {\n revokeRefreshToken(id: $id)\n }\n"): (typeof documents)["\n mutation RevokeRefreshToken($id: Id!) {\n revokeRefreshToken(id: $id)\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/gql-types/graphql.ts b/src/gql-types/graphql.ts index 1dca4e6c48..231e807659 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -1029,6 +1029,8 @@ export type MutationRoot = { * Share the returned token with the intended recipient out-of-band. */ createInviteLink: InviteLink; + /** Create a refresh token for the authenticated user. */ + createRefreshToken: RefreshTokenResult; /** * Create a storage mapping for the given catalog prefix. * @@ -1053,6 +1055,14 @@ export type MutationRoot = { * catalog prefix with the specified capability. */ redeemInviteLink: RedeemInviteLinkResult; + /** + * Revoke a refresh token owned by the authenticated user. + * + * Rather than deleting the row, we zero its `valid_for` interval, which + * marks the token as expired/invalid while preserving the audit trail. + * Already-zeroed (revoked) tokens are treated as not found. + */ + revokeRefreshToken: Scalars['Boolean']['output']; setBillingPaymentMethod: BillingPaymentMethodPayload; /** * Check storage health for a given catalog prefix and storage definition. @@ -1134,6 +1144,13 @@ export type MutationRootCreateInviteLinkArgs = { }; +export type MutationRootCreateRefreshTokenArgs = { + detail?: InputMaybe; + multiUse?: Scalars['Boolean']['input']; + validFor?: Scalars['String']['input']; +}; + + export type MutationRootCreateStorageMappingArgs = { catalogPrefix: Scalars['Prefix']['input']; detail?: InputMaybe; @@ -1163,6 +1180,11 @@ export type MutationRootRedeemInviteLinkArgs = { }; +export type MutationRootRevokeRefreshTokenArgs = { + id: Scalars['Id']['input']; +}; + + export type MutationRootSetBillingPaymentMethodArgs = { paymentMethodId: Scalars['String']['input']; tenant: Scalars['String']['input']; @@ -1430,6 +1452,8 @@ export type QueryRoot = { */ liveSpecs: LiveSpecRefConnection; prefixes: PrefixRefConnection; + /** List refresh tokens owned by the authenticated user. */ + refreshTokens: RefreshTokenInfoConnection; /** * Returns storage mappings accessible to the current user. * @@ -1513,6 +1537,12 @@ export type QueryRootPrefixesArgs = { }; +export type QueryRootRefreshTokensArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + + export type QueryRootStorageMappingsArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1535,6 +1565,44 @@ export type RedeemInviteLinkResult = { catalogPrefix: Scalars['Prefix']['output']; }; +export type RefreshTokenInfo = { + __typename?: 'RefreshTokenInfo'; + createdAt: Scalars['DateTime']['output']; + detail?: Maybe; + /** + * True once the token's validity window has elapsed + * (now is past `updated_at + valid_for`). + */ + expired: Scalars['Boolean']['output']; + id: Scalars['Id']['output']; + multiUse: Scalars['Boolean']['output']; + updatedAt: Scalars['DateTime']['output']; + uses: Scalars['Int']['output']; +}; + +export type RefreshTokenInfoConnection = { + __typename?: 'RefreshTokenInfoConnection'; + /** A list of edges. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +/** An edge in a connection. */ +export type RefreshTokenInfoEdge = { + __typename?: 'RefreshTokenInfoEdge'; + /** A cursor for use in pagination */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge */ + node: RefreshTokenInfo; +}; + +export type RefreshTokenResult = { + __typename?: 'RefreshTokenResult'; + id: Scalars['Id']['output']; + secret: Scalars['String']['output']; +}; + export type RepublishRequested = { __typename?: 'RepublishRequested'; /** @@ -1957,6 +2025,30 @@ export type LiveSpecsQueryQueryVariables = Exact<{ export type LiveSpecsQueryQuery = { __typename?: 'QueryRoot', liveSpecs: { __typename?: 'LiveSpecRefConnection', edges: Array<{ __typename?: 'LiveSpecRefEdge', cursor: string, node: { __typename?: 'LiveSpecRef', catalogName: any, liveSpec?: { __typename?: 'LiveSpec', catalogType: CatalogType } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null } } }; +export type RefreshTokensQueryVariables = Exact<{ + first?: InputMaybe; + after?: InputMaybe; +}>; + + +export type RefreshTokensQuery = { __typename?: 'QueryRoot', refreshTokens: { __typename?: 'RefreshTokenInfoConnection', edges: Array<{ __typename?: 'RefreshTokenInfoEdge', cursor: string, node: { __typename?: 'RefreshTokenInfo', id: any, detail?: string | null, createdAt: any, uses: number, expired: boolean } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; + +export type CreateRefreshTokenMutationVariables = Exact<{ + detail?: InputMaybe; + multiUse: Scalars['Boolean']['input']; + validFor: Scalars['String']['input']; +}>; + + +export type CreateRefreshTokenMutation = { __typename?: 'MutationRoot', createRefreshToken: { __typename?: 'RefreshTokenResult', id: any, secret: string } }; + +export type RevokeRefreshTokenMutationVariables = Exact<{ + id: Scalars['Id']['input']; +}>; + + +export type RevokeRefreshTokenMutation = { __typename?: 'MutationRoot', revokeRefreshToken: boolean }; + export type CreateStorageMappingMutationVariables = Exact<{ catalogPrefix: Scalars['Prefix']['input']; spec: Scalars['JSON']['input']; @@ -2044,6 +2136,9 @@ export const CreateInviteLinkDocument = {"kind":"Document","definitions":[{"kind export const DeleteInviteLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteInviteLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteInviteLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}]}]}}]} as unknown as DocumentNode; export const RedeemInviteLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RedeemInviteLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"redeemInviteLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"capability"}},{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}}]}}]}}]} as unknown as DocumentNode; export const LiveSpecsQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LiveSpecsQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"liveSpecs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"liveSpec"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const RefreshTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"RefreshTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"refreshTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"uses"}},{"kind":"Field","name":{"kind":"Name","value":"expired"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageInfoFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageInfoFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]} as unknown as DocumentNode; +export const CreateRefreshTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRefreshToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"multiUse"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"validFor"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRefreshToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}},{"kind":"Argument","name":{"kind":"Name","value":"multiUse"},"value":{"kind":"Variable","name":{"kind":"Name","value":"multiUse"}}},{"kind":"Argument","name":{"kind":"Name","value":"validFor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"validFor"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]}}]} as unknown as DocumentNode; +export const RevokeRefreshTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeRefreshToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Id"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"revokeRefreshToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const CreateStorageMappingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStorageMapping"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogPrefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spec"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createStorageMapping"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogPrefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogPrefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"spec"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spec"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}}]}}]}}]} as unknown as DocumentNode; export const UpdateStorageMappingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateStorageMapping"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogPrefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spec"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateStorageMapping"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogPrefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogPrefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"spec"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spec"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"republish"}}]}}]}}]} as unknown as DocumentNode; export const TestConnectionHealthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TestConnectionHealth"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogPrefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spec"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testConnectionHealth"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogPrefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogPrefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"spec"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spec"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"results"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fragmentStore"}},{"kind":"Field","name":{"kind":"Name","value":"dataPlaneName"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/gql-types/schema.graphql b/src/gql-types/schema.graphql index bf2a418ac7..66fbf3c76e 100644 --- a/src/gql-types/schema.graphql +++ b/src/gql-types/schema.graphql @@ -1053,6 +1053,17 @@ type MutationRoot { """ createInviteLink(capability: Capability!, catalogPrefix: Prefix!, detail: String, singleUse: Boolean! = true): InviteLink! + """Create a refresh token for the authenticated user.""" + createRefreshToken( + detail: String = null + multiUse: Boolean! = true + + """ + ISO 8601 duration for token validity (e.g. P90D); must be greater than zero and at most one year + """ + validFor: String! = "P90D" + ): RefreshTokenResult! + """ Create a storage mapping for the given catalog prefix. @@ -1082,6 +1093,15 @@ type MutationRoot { catalog prefix with the specified capability. """ redeemInviteLink(token: UUID!): RedeemInviteLinkResult! + + """ + Revoke a refresh token owned by the authenticated user. + + Rather than deleting the row, we zero its `valid_for` interval, which + marks the token as expired/invalid while preserving the audit trail. + Already-zeroed (revoked) tokens are treated as not found. + """ + revokeRefreshToken(id: Id!): Boolean! setBillingPaymentMethod(paymentMethodId: String!, tenant: String!): BillingPaymentMethodPayload! """ @@ -1424,6 +1444,9 @@ type QueryRoot { liveSpecs(after: String, before: String, by: LiveSpecsBy!, first: Int, last: Int): LiveSpecRefConnection! prefixes(after: String, by: PrefixesBy!, first: Int): PrefixRefConnection! + """List refresh tokens owned by the authenticated user.""" + refreshTokens(after: String, first: Int): RefreshTokenInfoConnection! + """ Returns storage mappings accessible to the current user. @@ -1443,6 +1466,43 @@ type RedeemInviteLinkResult { catalogPrefix: Prefix! } +type RefreshTokenInfo { + createdAt: DateTime! + detail: String + + """ + True once the token's validity window has elapsed + (now is past `updated_at + valid_for`). + """ + expired: Boolean! + id: Id! + multiUse: Boolean! + updatedAt: DateTime! + uses: Int! +} + +type RefreshTokenInfoConnection { + """A list of edges.""" + edges: [RefreshTokenInfoEdge!]! + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type RefreshTokenInfoEdge { + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: RefreshTokenInfo! +} + +type RefreshTokenResult { + id: Id! + secret: String! +} + type RepublishRequested { """ The `last_build_id` of the live spec as of when the `Republish` message diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 624ac51642..17288b847b 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -12,17 +12,25 @@ export const AdminPage: Record = { 'admin.cli_api.message': `Use Refresh and Access tokens to connect to ${CommonMessages.productName} programmatically.`, 'admin.cli_api.accessToken': `Access Token`, 'admin.cli_api.accessToken.message': `Access tokens enable authentication using flowctl.`, - 'admin.cli_api.refreshToken': `Refresh Token`, - 'admin.cli_api.refreshToken.message': `Refresh tokens enable programmatic access to most services including the Kafka compatible API “dekaf”.`, - 'admin.cli_api.refreshToken.cta.generate': `Generate Token`, - 'admin.cli_api.refreshToken.table.noContent.header': `No refresh tokens found.`, - 'admin.cli_api.refreshToken.table.noContent.message': `To create a refresh token, click "Generate Token" above.`, - 'admin.cli_api.refreshToken.table.filterLabel': `Filter by Description`, + 'admin.cli_api.refreshToken.header': `Refresh Tokens`, + 'admin.cli_api.refreshToken.message': `Refresh tokens enable programmatic access to most services including the Kafka compatible API "dekaf".`, + 'admin.cli_api.refreshToken.cta.create': `Create Refresh Token`, + 'admin.cli_api.refreshToken.table.error': `There was an error loading refresh tokens.`, + 'admin.cli_api.refreshToken.table.column.label': `Label`, + 'admin.cli_api.refreshToken.table.column.uses': `Uses`, 'admin.cli_api.refreshToken.table.label.uses': `Used {count} {count, plural, one {time} other {times}}`, - 'admin.cli_api.refreshToken.dialog.header': `Generate Refresh Token`, - 'admin.cli_api.refreshToken.dialog.label': `What's this token for?`, - 'admin.cli_api.refreshToken.dialog.alert.copyToken': `Make sure to copy your refresh token now. You won't be able to see it again!`, + 'admin.cli_api.refreshToken.table.status.expired': `Expired`, + 'admin.cli_api.refreshToken.table.noContent.header': `No refresh tokens found.`, + 'admin.cli_api.refreshToken.table.noContent.cta': `Create one now`, + 'admin.cli_api.refreshToken.dialog.header': `Create Refresh Token`, + 'admin.cli_api.refreshToken.dialog.label': `Label`, + 'admin.cli_api.refreshToken.dialog.cta.create': `Create`, + 'admin.cli_api.refreshToken.dialog.alert.copyToken': `Copy this refresh token now - you won't be able to see it again!`, 'admin.cli_api.refreshToken.dialog.alert.tokenEncodingFailed': `An issue was encountered displaying your token. Please generate a new token.`, + 'admin.cli_api.refreshToken.revoke.header': `Remove Refresh Token`, + 'admin.cli_api.refreshToken.revoke.message': `Remove this refresh token?`, + 'admin.cli_api.refreshToken.revoke.message.named': `Remove the refresh token "{detail}"?`, + 'admin.cli_api.refreshToken.revoke.permanent': `This action is permanent.`, 'admin.billing.header': `Billing`, 'admin.billing.message.freeTier': `The free tier lets you try ${CommonMessages.productName} with up to 2 tasks and 10GB per month without entering a credit card. Usage beyond these limits automatically starts a 30 day free trial.`, diff --git a/src/lang/en-US/EntityTable.ts b/src/lang/en-US/EntityTable.ts index c69d1fdd51..1f3cb6a960 100644 --- a/src/lang/en-US/EntityTable.ts +++ b/src/lang/en-US/EntityTable.ts @@ -14,7 +14,6 @@ export const EntityTable: Record = { 'entityTable.data.actions': `Actions`, 'entityTable.data.writesTo': `${Data['data.writes_to']}`, 'entityTable.data.readsFrom': `${Data['data.reads_from']}`, - 'entityTable.data.status': `Status`, 'entityTable.data.userFullName': `Name`, 'entityTable.data.capability': `Capability`, 'entityTable.data.detail': `Detail`, diff --git a/src/services/supabase.ts b/src/services/supabase.ts index 4dc53c634e..0dd508aa0e 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -73,7 +73,6 @@ export enum TABLES { // PUBLICATION_SPECS = 'publication_specs', PUBLICATION_SPECS_EXT = 'publication_specs_ext', PUBLICATIONS = 'publications', - REFRESH_TOKENS = 'refresh_tokens', ROLE_GRANTS = 'role_grants', STORAGE_MAPPINGS = 'storage_mappings', TASKS_BY_DAY = 'task_stats_by_day', @@ -86,7 +85,6 @@ export enum TABLES { export enum RPCS { AUTH_ROLES = 'auth_roles', // BILLING_REPORT = 'billing_report_202308', - CREATE_REFRESH_TOKEN = 'create_refresh_token', DRAFT_COLLECTIONS_ELIGIBLE_FOR_DELETION = 'draft_collections_eligible_for_deletion', EXCHANGE_DIRECTIVES = 'exchange_directive_token', REPUBLISH_PREFIX = 'republish_prefix', diff --git a/src/stores/Tables/hooks.ts b/src/stores/Tables/hooks.ts index eac15998d4..a7d3ba183d 100644 --- a/src/stores/Tables/hooks.ts +++ b/src/stores/Tables/hooks.ts @@ -35,7 +35,6 @@ export enum TablePrefixes { materializations = 'mat', prefixes = 'pr', prefixAlerts = 'pal', - refreshTokens = 'rt', schemaViewer = 'sv', storageMappings = 'sm', } diff --git a/src/stores/names.ts b/src/stores/names.ts index 750bd816eb..adfda46973 100644 --- a/src/stores/names.ts +++ b/src/stores/names.ts @@ -44,7 +44,6 @@ export enum SelectTableStoreNames { CONNECTOR = 'Connectors-Table', MATERIALIZATION = 'Materializations-Table', PREFIX_ALERTS = 'Prefix-Alert-Table', - REFRESH_TOKENS = 'Refresh-Tokens-Table', STORAGE_MAPPINGS = 'Storage-Mappings-Table', } diff --git a/src/types/index.ts b/src/types/index.ts index 5bd1b37f75..b125e16d52 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -327,11 +327,6 @@ export interface UserDetails { usedSSO: boolean; } -export interface RefreshTokenData { - id: string; - secret: string; -} - export interface BindingMetadata { bindingIndex: number; collection: string; From e76b1dd3adb424ca247ad7068accd9bf6b5ee34f Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 09:30:09 -0400 Subject: [PATCH 03/16] Key billing data on the selected tenant via SWR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Billing invoices are now fetched through a tenant-keyed SWR hook (useBillingInvoices) instead of an imperative fetch into the Billing store. Because the SWR key derives from the selected tenant and the rolling six-month window, switching orgs re-fetches automatically and never shows the previous tenant's data — so the store no longer needs to be manually reset when the tenant changes. Removes the useTenantChangeReset hook, the unmount reset in AdminBilling, and the StoreWithHydration machinery (invoices, active/hydrated/ networkFailed/hydrationErrorsExist, resetState) from the Billing store, which is now just the invoice selection and paymentMethodExists. The selected invoice resolves to the stored selection or falls back to the newest invoice, so a stale selection from another tenant self-corrects. All invoice/loading/error reads across the billing page, history table, line-items table, usage graphs, and graph-state wrapper now come from the hook. SWR dedupes the shared key to a single request, and revisiting a tenant is served instantly from cache. --- src/components/admin/Billing/LoadError.tsx | 8 +- .../admin/Billing/PricingTierDetails.tsx | 3 +- .../admin/Billing/TenantOptions.tsx | 11 +-- src/components/admin/Billing/index.tsx | 92 +------------------ .../graphs/TaskHoursByMonthGraph.tsx | 9 +- src/components/graphs/UsageByMonthGraph.tsx | 9 +- src/components/graphs/states/Wrapper.tsx | 13 +-- src/components/tables/BillLineItems/index.tsx | 14 +-- src/components/tables/Billing/index.tsx | 19 ++-- src/hooks/billing/useBillingInvoices.ts | 79 ++++++++++++++++ src/stores/Billing.ts | 60 +----------- 11 files changed, 117 insertions(+), 200 deletions(-) create mode 100644 src/hooks/billing/useBillingInvoices.ts diff --git a/src/components/admin/Billing/LoadError.tsx b/src/components/admin/Billing/LoadError.tsx index 630fb98d4c..34a430006d 100644 --- a/src/components/admin/Billing/LoadError.tsx +++ b/src/components/admin/Billing/LoadError.tsx @@ -3,14 +3,12 @@ import { Grid } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import AlertBox from 'src/components/shared/AlertBox'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; function BillingLoadError() { - const hydrationErrorsExist = useBillingStore( - (state) => state.hydrationErrorsExist - ); + const { errorExists } = useBillingInvoices(); - if (!hydrationErrorsExist) { + if (!errorExists) { return null; } diff --git a/src/components/admin/Billing/PricingTierDetails.tsx b/src/components/admin/Billing/PricingTierDetails.tsx index 20db091910..8102906886 100644 --- a/src/components/admin/Billing/PricingTierDetails.tsx +++ b/src/components/admin/Billing/PricingTierDetails.tsx @@ -13,7 +13,6 @@ function PricingTierDetails() { const [externalPaymentMethod, marketPlaceProvider] = useTenantUsesExternalPayment(selectedTenant); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); const paymentMethodExists = useBillingStore( (state) => state.paymentMethodExists ); @@ -38,7 +37,7 @@ function PricingTierDetails() { return 'admin.billing.message.freeTier'; }, [externalPaymentMethod, marketPlaceProvider, paymentMethodExists]); - if (!billingStoreHydrated || typeof paymentMethodExists !== 'boolean') { + if (typeof paymentMethodExists !== 'boolean') { return ( diff --git a/src/components/admin/Billing/TenantOptions.tsx b/src/components/admin/Billing/TenantOptions.tsx index 17466aaa7a..54d6599057 100644 --- a/src/components/admin/Billing/TenantOptions.tsx +++ b/src/components/admin/Billing/TenantOptions.tsx @@ -1,16 +1,7 @@ -import { useCallback } from 'react'; - import TenantSelector from 'src/components/shared/TenantSelector'; -import { useBillingStore } from 'src/stores/Billing'; function TenantOptions() { - const resetBillingState = useBillingStore((state) => state.resetState); - - const updateStoreState = useCallback(() => { - resetBillingState(); - }, [resetBillingState]); - - return ; + return ; } export default TenantOptions; diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index 4e2892fc40..3c0b8e8cb9 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -1,18 +1,10 @@ import type { AdminBillingProps } from 'src/components/admin/Billing/types'; -import { useEffect, useMemo } from 'react'; -import useConstant from 'use-constant'; - import { Divider, Grid, Typography } from '@mui/material'; -import { useShallow } from 'zustand/react/shallow'; - -import { endOfMonth, startOfMonth, subMonths } from 'date-fns'; import { ErrorBoundary } from 'react-error-boundary'; import { useIntl } from 'react-intl'; -import { useUnmount } from 'react-use'; -import { getInvoicesBetween } from 'src/api/billing'; import { authenticatedRoutes } from 'src/app/routes'; import DateRange from 'src/components/admin/Billing/DateRange'; import BillingLoadError from 'src/components/admin/Billing/LoadError'; @@ -28,14 +20,10 @@ import AlertBox from 'src/components/shared/AlertBox'; import CardWrapper from 'src/components/shared/CardWrapper'; import BillingHistoryTable from 'src/components/tables/Billing'; import BillingLineItemsTable from 'src/components/tables/BillLineItems'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import usePageTitle from 'src/hooks/usePageTitle'; import { logRocketEvent } from 'src/services/shared'; import { CustomEvents } from 'src/services/types'; -import { - useBilling_selectedInvoice, - useBillingStore, -} from 'src/stores/Billing'; -import { useTenantStore } from 'src/stores/Tenant'; import { invoiceId, TOTAL_CARD_HEIGHT } from 'src/utils/billing-utils'; const routeTitle = authenticatedRoutes.admin.billing.title; @@ -52,79 +40,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { const intl = useIntl(); - const selectedTenant = useTenantStore((state) => state.selectedTenant); - - // Billing Store - // TODO (billing store) - // The `active` stuff could probably be removed now that other stuff is - // cleaned up - but leaving to make it easier - const [active, setActive] = useBillingStore( - useShallow((state) => [state.active, state.setActive]) - ); - const [hydrated, setHydrated] = useBillingStore( - useShallow((state) => [state.hydrated, state.setHydrated]) - ); - const setHydrationErrorsExist = useBillingStore( - (state) => state.setHydrationErrorsExist - ); - const setInvoices = useBillingStore((state) => state.setInvoices); - const setNetworkFailed = useBillingStore((state) => state.setNetworkFailed); - - const selectedInvoice = useBilling_selectedInvoice(); - - const resetBillingState = useBillingStore((state) => state.resetState); - - const currentMonth = useConstant(() => { - const today = new Date(); - - return endOfMonth(today); - }); - - const dateRange = useMemo(() => { - const startMonth = startOfMonth(subMonths(currentMonth, 5)); - - return { start: startMonth, end: currentMonth }; - }, [currentMonth]); - - useEffect(() => { - if (selectedTenant) { - void (async () => { - setNetworkFailed(null); - setActive(true); - try { - const response = await getInvoicesBetween( - selectedTenant, - dateRange.start, - dateRange.end - ); - if (response.error) { - throw new Error(response.error.message); - } - setNetworkFailed(null); - setHydrationErrorsExist(false); - setInvoices(response.data); - } catch (errorMessage: unknown) { - setNetworkFailed(`${errorMessage}`); - setHydrationErrorsExist(true); - setInvoices([]); - } finally { - setHydrated(true); - setActive(false); - } - })(); - } - }, [ - dateRange.end, - dateRange.start, - selectedTenant, - setActive, - setHydrated, - setHydrationErrorsExist, - setInvoices, - setNetworkFailed, - ]); - - useUnmount(() => resetBillingState()); + const { isLoading, selectedInvoice } = useBillingInvoices(); return ( <> @@ -178,7 +94,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - {!active && hydrated ? ( + {!isLoading ? ( state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const resizeListener = useRef(null); const [myChart, setMyChart] = useState(null); @@ -85,7 +84,7 @@ function TaskHoursByMonthGraph() { }, [invoices, intl, today]); useEffect(() => { - if (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -190,7 +189,7 @@ function TaskHoursByMonthGraph() { } }, [ invoices, - billingStoreHydrated, + isLoading, intl, months, myChart, diff --git a/src/components/graphs/UsageByMonthGraph.tsx b/src/components/graphs/UsageByMonthGraph.tsx index 0de4754ab3..20d6624c4a 100644 --- a/src/components/graphs/UsageByMonthGraph.tsx +++ b/src/components/graphs/UsageByMonthGraph.tsx @@ -27,7 +27,7 @@ import { useIntl } from 'react-intl'; import useLegendConfig from 'src/components/graphs/useLegendConfig'; import useTooltipConfig from 'src/components/graphs/useTooltipConfig'; import { eChartsColors } from 'src/context/Theme'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { CARD_AREA_HEIGHT, stripTimeFromDate } from 'src/utils/billing-utils'; const chartContainerId = 'data-by-month'; @@ -40,8 +40,7 @@ function UsageByMonthGraph() { const tooltipConfig = useTooltipConfig(); const legendConfig = useLegendConfig([{ name: 'Data' }, { name: 'Hours' }]); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const [myChart, setMyChart] = useState(null); @@ -96,7 +95,7 @@ function UsageByMonthGraph() { }, [invoices, intl, today]); useEffect(() => { - if (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -126,7 +125,7 @@ function UsageByMonthGraph() { return undefined; }, [ invoices, - billingStoreHydrated, + isLoading, intl, legendConfig, months, diff --git a/src/components/graphs/states/Wrapper.tsx b/src/components/graphs/states/Wrapper.tsx index 00693b82a2..f81e410f4e 100644 --- a/src/components/graphs/states/Wrapper.tsx +++ b/src/components/graphs/states/Wrapper.tsx @@ -7,14 +7,15 @@ import { FormattedMessage } from 'react-intl'; import EmptyGraphState from 'src/components/graphs/states/Empty'; import GraphLoadingState from 'src/components/graphs/states/Loading'; import { eChartsTooltipSX } from 'src/components/graphs/tooltips'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { hasLength } from 'src/utils/misc-utils'; function GraphStateWrapper({ children }: BaseComponentProps) { - const billingStoreActive = useBillingStore((state) => state.active); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const networkFailed = useBillingStore((state) => state.networkFailed); - const billingHistory = useBillingStore((state) => state.invoices); + const { + invoices: billingHistory, + isLoading, + networkFailed, + } = useBillingInvoices(); if (networkFailed) { return ( @@ -29,7 +30,7 @@ function GraphStateWrapper({ children }: BaseComponentProps) { ); } - if (!billingStoreActive && billingStoreHydrated) { + if (!isLoading) { return hasLength(billingHistory) ? ( {children} ) : ( diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index 5c1dcbce40..69ed0244ac 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -22,10 +22,7 @@ import TotalLines from 'src/components/tables/BillLineItems/TotalLines'; import EntityTableBody from 'src/components/tables/EntityTable/TableBody'; import EntityTableHeader from 'src/components/tables/EntityTable/TableHeader'; import { getTableHeaderWithoutHeaderColor } from 'src/context/Theme'; -import { - useBilling_selectedInvoice, - useBillingStore, -} from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { useTenantStore } from 'src/stores/Tenant'; import { TableStatuses } from 'src/types'; @@ -57,10 +54,7 @@ function BillingLineItemsTable() { const selectedTenant = useTenantStore((state) => state.selectedTenant); - const selectedInvoice = useBilling_selectedInvoice(); - - const hydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, selectedInvoice, isLoading } = useBillingInvoices(); const dataRows = useMemo( () => , @@ -120,7 +114,7 @@ function BillingLineItemsTable() { ? { status: TableStatuses.DATA_FETCHED } : { status: TableStatuses.NO_EXISTING_DATA } } - loading={!hydrated} + loading={isLoading} rows={dataRows} /> @@ -135,7 +129,7 @@ function BillingLineItemsTable() { }} > {selectedInvoice?.invoice_type !== 'preview' ? ( - hydrated ? ( + !isLoading ? ( + {selectedInvoice.receipt_url ? ( + + ) : selectedInvoice.status === 'open' ? ( + + ) : selectedInvoice.status === 'paid' ? ( - {stripeInvoice?.status === 'open' ? ( - - ) : stripeInvoice?.status === 'paid' ? ( - - ) : ( - - )} - - ) : ( - <> - - } + disabled sx={{ marginLeft: 1 }} - variant="rectangular" - width={120} - height={25} - /> - - ) + variant="outlined" + size="small" + > + {intl.formatMessage({ + id: 'admin.billing.table.line_items.tooltip.pay_invoice', + })} + + )} + ) : null} {selectedInvoice ? ( diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index 8aa7617f72..50be49072c 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -19,7 +19,7 @@ type Documents = { "\n query AlertSubscriptions($prefix: Prefix!) {\n alertSubscriptions(by: { prefix: $prefix }) {\n alertTypes\n catalogPrefix\n email\n updatedAt\n }\n }\n": typeof types.AlertSubscriptionsDocument, "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": typeof types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": typeof types.AlertTypeDocument, - "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n": typeof types.TenantBillingInvoicesDocument, + "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": typeof types.TenantBillingInvoicesDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": typeof types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.DataPlanesDocument, @@ -48,7 +48,7 @@ const documents: Documents = { "\n query AlertSubscriptions($prefix: Prefix!) {\n alertSubscriptions(by: { prefix: $prefix }) {\n alertTypes\n catalogPrefix\n email\n updatedAt\n }\n }\n": types.AlertSubscriptionsDocument, "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": types.AlertTypeDocument, - "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n": types.TenantBillingInvoicesDocument, + "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": types.TenantBillingInvoicesDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.DataPlanesDocument, @@ -109,7 +109,7 @@ export function graphql(source: "\n query AlertType {\n alertTypes {\n /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n"]; +export function graphql(source: "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/gql-types/graphql.ts b/src/gql-types/graphql.ts index 544e870f41..00e609867d 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -1968,7 +1968,7 @@ export type TenantBillingInvoicesQueryVariables = Exact<{ }>; -export type TenantBillingInvoicesQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', invoices: { __typename?: 'InvoiceConnection', nodes: Array<{ __typename?: 'Invoice', dateStart: string, dateEnd: string, invoiceType: InvoiceType, subtotal: number, lineItems: any, extra: any }> } } } | null }; +export type TenantBillingInvoicesQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', invoices: { __typename?: 'InvoiceConnection', nodes: Array<{ __typename?: 'Invoice', dateStart: string, dateEnd: string, invoiceType: InvoiceType, subtotal: number, lineItems: any, extra: any, status?: string | null, invoicePdf?: string | null, hostedInvoiceUrl?: string | null, paymentDetails?: { __typename?: 'InvoicePaymentDetails', status: ChargeStatus, receiptUrl?: string | null } | null }> } } } | null }; export type ConnectorsGridQueryVariables = Exact<{ filter?: InputMaybe; @@ -2136,7 +2136,7 @@ export const DeleteAlertSubscriptionMutationDocument = {"kind":"Document","defin export const AlertSubscriptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertSubscriptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertSubscriptions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"}},{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const UpdateAlertSubscriptionMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAlertSubscriptionMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AlertType"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAlertSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"alertTypes"},"value":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const AlertTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertType"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"isDefault"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}}]}}]}}]} as unknown as DocumentNode; -export const TenantBillingInvoicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingInvoices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invoices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dateStart"}},{"kind":"Field","name":{"kind":"Name","value":"dateEnd"}},{"kind":"Field","name":{"kind":"Name","value":"invoiceType"}},{"kind":"Field","name":{"kind":"Name","value":"subtotal"}},{"kind":"Field","name":{"kind":"Name","value":"lineItems"}},{"kind":"Field","name":{"kind":"Name","value":"extra"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const TenantBillingInvoicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingInvoices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invoices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dateStart"}},{"kind":"Field","name":{"kind":"Name","value":"dateEnd"}},{"kind":"Field","name":{"kind":"Name","value":"invoiceType"}},{"kind":"Field","name":{"kind":"Name","value":"subtotal"}},{"kind":"Field","name":{"kind":"Name","value":"lineItems"}},{"kind":"Field","name":{"kind":"Name","value":"extra"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"invoicePdf"}},{"kind":"Field","name":{"kind":"Name","value":"hostedInvoiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"paymentDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"receiptUrl"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorsGridDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorsGrid"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectorsFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"500"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"recommended"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"defaultSpec"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorTagDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorTagData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connector"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"imageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"connectorSpec"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fullImageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"defaultCaptureInterval"}},{"kind":"Field","name":{"kind":"Name","value":"disableBackfill"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"endpointSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"resourceSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]} as unknown as DocumentNode; export const DataPlanesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DataPlanes"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataPlanes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"cloudProvider"}},{"kind":"Field","name":{"kind":"Name","value":"region"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"fqdn"}},{"kind":"Field","name":{"kind":"Name","value":"cidrBlocks"}},{"kind":"Field","name":{"kind":"Name","value":"awsIamUserArn"}},{"kind":"Field","name":{"kind":"Name","value":"gcpServiceAccountEmail"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationClientId"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationName"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/hooks/billing/useBillingInvoices.ts b/src/hooks/billing/useBillingInvoices.ts index edec14766f..7deb92092d 100644 --- a/src/hooks/billing/useBillingInvoices.ts +++ b/src/hooks/billing/useBillingInvoices.ts @@ -52,6 +52,10 @@ const mapInvoice = ( subtotal: number; lineItems: unknown; extra: unknown; + status?: string | null; + invoicePdf?: string | null; + hostedInvoiceUrl?: string | null; + paymentDetails?: { receiptUrl?: string | null } | null; }, tenant: string ): Invoice => ({ @@ -62,6 +66,10 @@ const mapInvoice = ( subtotal: node.subtotal, line_items: (node.lineItems ?? []) as InvoiceLineItem[], extra: (node.extra ?? undefined) as Invoice['extra'], + status: node.status, + invoice_pdf: node.invoicePdf, + hosted_invoice_url: node.hostedInvoiceUrl, + receipt_url: node.paymentDetails?.receiptUrl ?? null, }); // Mirrors the predicate the previous PostgREST query enforced server-side: diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 17288b847b..4da80c6e83 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -86,6 +86,7 @@ export const AdminPage: Record = { 'admin.billing.table.line_items.tooltip.download_pdf': `Download invoice PDF`, 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, + 'admin.billing.table.line_items.tooltip.view_receipt': `View Receipt`, 'admin.billing.paymentMethods.header': `Payment Information`, 'admin.billing.paymentMethods.description': `Enter your payment information. You won't be charged until your account usage exceeds free tier limits.`, From 929a301b54012d5b552e0f559c2a3a208a91b600 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 14:00:53 -0400 Subject: [PATCH 08/16] Show 'No invoice available' and open invoice links in a new tab When the selected invoice has no Stripe artifact (no PDF, hosted page, receipt, or status), replace the disabled Download/Pay buttons with a single 'No invoice available' button rather than two dead controls. The external invoice links (Download PDF, Pay Invoice, View Receipt) now open in a new tab (component=a + target=_blank, rel=noopener noreferrer) so they don't navigate the user out of the app. --- src/components/tables/BillLineItems/index.tsx | 144 +++++++++++------- src/lang/en-US/AdminPage.ts | 1 + 2 files changed, 87 insertions(+), 58 deletions(-) diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index f16e01b78b..f59884a866 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -55,6 +55,17 @@ function BillingLineItemsTable() { [selectedInvoice] ); + // Whether the selected invoice has any Stripe artifact (PDF, hosted page, + // receipt, or a status). When it has none, there's nothing to download or + // pay, so we show a single "No invoice available" button instead. + const hasInvoiceArtifact = Boolean( + selectedInvoice && + (selectedInvoice.invoice_pdf || + selectedInvoice.hosted_invoice_url || + selectedInvoice.receipt_url || + selectedInvoice.status) + ); + return ( <> @@ -103,71 +114,88 @@ function BillingLineItemsTable() { > {selectedInvoice && selectedInvoice.invoice_type !== 'preview' ? ( - - - {selectedInvoice.receipt_url ? ( + hasInvoiceArtifact ? ( + - ) : selectedInvoice.status === 'open' ? ( - - ) : selectedInvoice.status === 'paid' ? ( - - ) : ( - - )} - + {selectedInvoice.receipt_url ? ( + + ) : selectedInvoice.status === 'open' ? ( + + ) : selectedInvoice.status === 'paid' ? ( + + ) : ( + + )} + + ) : ( + + ) ) : null} {selectedInvoice ? ( diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 4da80c6e83..ef5220229a 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -87,6 +87,7 @@ export const AdminPage: Record = { 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, 'admin.billing.table.line_items.tooltip.view_receipt': `View Receipt`, + 'admin.billing.table.line_items.tooltip.no_invoice': `No invoice available`, 'admin.billing.paymentMethods.header': `Payment Information`, 'admin.billing.paymentMethods.description': `Enter your payment information. You won't be charged until your account usage exceeds free tier limits.`, From 1605b52cf73b6f7b90f31cc41b9eb422f4bd1722 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 22:52:54 -0400 Subject: [PATCH 09/16] Reset billing history pagination on tenant change, not on data identity BillingHistoryTable reset to the first page whenever the allInvoices array changed reference. The billing query is keyless and revalidates on the 30s requestPolicy TTL, so a background refetch handed back a new but identical array and bounced the user back to page one. Key the reset on the selected tenant instead, the only case where returning to the newest invoices is wanted, and expose selectedTenant from useBillingInvoices. The existing currentPage clamp still keeps the view in range when the invoice count changes. --- src/components/tables/Billing/index.tsx | 18 ++++++++++++------ src/hooks/billing/useBillingInvoices.ts | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/tables/Billing/index.tsx b/src/components/tables/Billing/index.tsx index d4191ac173..0541abd888 100644 --- a/src/components/tables/Billing/index.tsx +++ b/src/components/tables/Billing/index.tsx @@ -44,17 +44,23 @@ const ROWS_PER_PAGE = 4; function BillingHistoryTable() { const intl = useIntl(); - const { allInvoices, selectedInvoice, isLoading, networkFailed } = - useBillingInvoices(); + const { + allInvoices, + selectedInvoice, + isLoading, + networkFailed, + selectedTenant, + } = useBillingInvoices(); const [page, setPage] = useState(0); - // The selected tenant lives behind the hook; reset to the first (newest) - // page whenever the invoice set changes so a tenant switch starts at the - // top rather than stranding the view on a now-out-of-range page. + // Return to the first (newest) page when the tenant changes, so a tenant + // switch starts at the top; refreshes within the same tenant keep the + // current page. The currentPage clamp below keeps the view in range when + // the invoice count changes. useEffect(() => { setPage(0); - }, [allInvoices]); + }, [selectedTenant]); const pageCount = Math.ceil(allInvoices.length / ROWS_PER_PAGE); const currentPage = Math.min(page, Math.max(0, pageCount - 1)); diff --git a/src/hooks/billing/useBillingInvoices.ts b/src/hooks/billing/useBillingInvoices.ts index 7deb92092d..47d338154b 100644 --- a/src/hooks/billing/useBillingInvoices.ts +++ b/src/hooks/billing/useBillingInvoices.ts @@ -35,6 +35,9 @@ export interface UseBillingInvoicesResult { isLoading: boolean; networkFailed: boolean; errorExists: boolean; + // The tenant these invoices belong to, exposed so views can respond to a + // tenant change — e.g. the history table resets pagination on a switch. + selectedTenant: string; } interface DateWindow { @@ -153,5 +156,6 @@ export function useBillingInvoices(): UseBillingInvoicesResult { isLoading: fetching, networkFailed: Boolean(error?.networkError), errorExists: Boolean(error), + selectedTenant, }; } From 66bf6dd47afb23a5f9bfda8c5526b780f9ffa57f Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 00:00:18 -0400 Subject: [PATCH 10/16] layout updates --- src/components/admin/Billing/LoadError.tsx | 20 ++- src/components/admin/Billing/index.tsx | 117 ++++++++---------- src/components/tables/BillLineItems/Rows.tsx | 4 +- .../tables/BillLineItems/TotalLines.tsx | 5 +- src/components/tables/BillLineItems/index.tsx | 1 - src/components/tables/Billing/Rows.tsx | 19 ++- src/components/tables/Billing/index.tsx | 17 +-- src/lang/en-US/AdminPage.ts | 10 +- 8 files changed, 83 insertions(+), 110 deletions(-) diff --git a/src/components/admin/Billing/LoadError.tsx b/src/components/admin/Billing/LoadError.tsx index 34a430006d..d9d07e3b77 100644 --- a/src/components/admin/Billing/LoadError.tsx +++ b/src/components/admin/Billing/LoadError.tsx @@ -1,5 +1,3 @@ -import { Grid } from '@mui/material'; - import { FormattedMessage } from 'react-intl'; import AlertBox from 'src/components/shared/AlertBox'; @@ -13,17 +11,13 @@ function BillingLoadError() { } return ( - - - } - > - - - + } + > + + ); } diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index 3c0b8e8cb9..d57bbfbfde 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -1,6 +1,6 @@ import type { AdminBillingProps } from 'src/components/admin/Billing/types'; -import { Divider, Grid, Typography } from '@mui/material'; +import { Divider, Grid, Stack, Typography } from '@mui/material'; import { ErrorBoundary } from 'react-error-boundary'; import { useIntl } from 'react-intl'; @@ -10,7 +10,6 @@ import DateRange from 'src/components/admin/Billing/DateRange'; import BillingLoadError from 'src/components/admin/Billing/LoadError'; import PaymentMethods from 'src/components/admin/Billing/PaymentMethods'; import PricingTierDetails from 'src/components/admin/Billing/PricingTierDetails'; -import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared'; import TenantOptions from 'src/components/admin/Billing/TenantOptions'; import AdminTabs from 'src/components/admin/Tabs'; import GraphLoadingState from 'src/components/graphs/states/Loading'; @@ -28,10 +27,6 @@ import { invoiceId, TOTAL_CARD_HEIGHT } from 'src/utils/billing-utils'; const routeTitle = authenticatedRoutes.admin.billing.title; -// Adding the height of a row generally works and should make it -// not _too_ tall -const invoiceCardHeight = TOTAL_CARD_HEIGHT + INVOICE_ROW_HEIGHT; - function AdminBilling({ showAddPayment }: AdminBillingProps) { usePageTitle({ header: routeTitle, @@ -63,36 +58,25 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - + - + - - - - - - - - - - )} - - - - - + - - - + + + + + + + + + + + {intl.formatMessage({ + id: 'admin.billing.paymentMethods.header', + })} + + + {intl.formatMessage({ - id: 'admin.billing.paymentMethods.header', + id: 'admin.billing.error.paymentMethodsError', })} - - - {intl.formatMessage({ - id: 'admin.billing.error.paymentMethodsError', - })} - - - - } - onError={(errorLoadingPaymentMethods) => { - logRocketEvent( - CustomEvents.ERROR_BOUNDARY_PAYMENT_METHODS, - { - stack: errorLoadingPaymentMethods.stack, - } - ); - }} - > - - - - + + + } + onError={(errorLoadingPaymentMethods) => { + logRocketEvent( + CustomEvents.ERROR_BOUNDARY_PAYMENT_METHODS, + { + stack: errorLoadingPaymentMethods.stack, + } + ); + }} + > + + + ); } diff --git a/src/components/tables/BillLineItems/Rows.tsx b/src/components/tables/BillLineItems/Rows.tsx index 91cb523b5b..c41018046e 100644 --- a/src/components/tables/BillLineItems/Rows.tsx +++ b/src/components/tables/BillLineItems/Rows.tsx @@ -17,7 +17,9 @@ function Row({ row, descriptionTooltip }: RowProps) { spacing={1} sx={{ alignItems: 'center' }} > - {row.description} + + {row.description} + {descriptionTooltip} diff --git a/src/components/tables/BillLineItems/TotalLines.tsx b/src/components/tables/BillLineItems/TotalLines.tsx index a1a8e52e82..62b22e4c93 100644 --- a/src/components/tables/BillLineItems/TotalLines.tsx +++ b/src/components/tables/BillLineItems/TotalLines.tsx @@ -19,10 +19,11 @@ function TotalLines({ invoice }: { invoice: Invoice }) { sx={{ flex: 1, display: 'flex', - justifyContent: 'space-between', + flexDirection: 'column', + alignItems: 'flex-end', }} > - + diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index f59884a866..9cb3613e57 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -107,7 +107,6 @@ function BillingLineItemsTable() { sx={{ display: 'flex', justifyContent: 'space-between', - marginTop: 2, flexGrow: 1, alignItems: 'end', }} diff --git a/src/components/tables/Billing/Rows.tsx b/src/components/tables/Billing/Rows.tsx index c913a0ad54..6663217f76 100644 --- a/src/components/tables/Billing/Rows.tsx +++ b/src/components/tables/Billing/Rows.tsx @@ -5,7 +5,6 @@ import { TableCell, TableRow, Typography } from '@mui/material'; import { FormattedMessage } from 'react-intl'; -import DataVolume from 'src/components/tables/cells/billing/DataVolume'; import TimeStamp from 'src/components/tables/cells/billing/TimeStamp'; import MonetaryValue from 'src/components/tables/cells/MonetaryValue'; import { useBillingStore } from 'src/stores/Billing'; @@ -33,24 +32,22 @@ function Row({ row, isSelected }: RowProps) { onClick={() => setSelectedInvoice(invoiceId(row))} sx={{ cursor: 'pointer' }} > - - - - + diff --git a/src/components/tables/Billing/index.tsx b/src/components/tables/Billing/index.tsx index 0541abd888..f0542a54b6 100644 --- a/src/components/tables/Billing/index.tsx +++ b/src/components/tables/Billing/index.tsx @@ -16,21 +16,13 @@ import { TableStatuses } from 'src/types'; import { invoiceId } from 'src/utils/billing-utils'; export const columns: TableColumns[] = [ - { - field: 'date_start', - headerIntlKey: 'admin.billing.table.history.label.date_start', - }, { field: 'date_end', headerIntlKey: 'admin.billing.table.history.label.date_end', }, { - field: 'data_volume', - headerIntlKey: 'admin.billing.table.history.label.dataVolume', - }, - { - field: 'task_usage', - headerIntlKey: 'admin.billing.table.history.label.tasks', + field: 'usage', + headerIntlKey: 'admin.billing.table.history.label.usage', }, { field: 'total_cost', @@ -90,10 +82,7 @@ function BillingHistoryTable() { id: 'entityTable.title', })} size="small" - sx={{ - ...getTableHeaderWithoutHeaderColor(), - minWidth: 450, - }} + sx={{ ...getTableHeaderWithoutHeaderColor() }} > diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index ef5220229a..e92dd2e26b 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -62,13 +62,13 @@ export const AdminPage: Record = { 'admin.billing.graph.taskHoursByMonth.formatValue': `{taskUsage} {taskUsage, plural, one {Hour} other {Hours}}`, 'admin.billing.graph.dataByTask.header': `Data Volume by Task`, 'admin.billing.graph.dataByTask.tooltip': `This graph displays the three, largest data processing tasks over the set interval.`, - 'admin.billing.table.history.header': `Recent History`, - 'admin.billing.table.history.label.dataVolume': `Data Volume`, + 'admin.billing.table.history.header': `Invoices`, 'admin.billing.table.history.label.details': `Pricing Tier`, 'admin.billing.table.history.label.date_start': `Start Date`, 'admin.billing.table.history.label.date_end': `End Date`, - 'admin.billing.table.history.label.tasks': `Task Usage`, + 'admin.billing.table.history.label.usage': `Data / Tasks`, 'admin.billing.table.history.label.totalCost': `Total Cost`, + 'admin.billing.table.history.value.usage': `{volume} GB / {tasks} Hr`, 'admin.billing.table.history.tooltip.date_start': `This billing period began on {timestamp}`, 'admin.billing.table.history.tooltip.date_end': `This billing period ended on {timestamp}`, 'admin.billing.table.history.tooltip.dataVolume': `GB of data processed by tasks`, @@ -83,10 +83,10 @@ export const AdminPage: Record = { 'admin.billing.table.line_items.label.total': `Total Due`, 'admin.billing.table.line_items.emptyTableDefault.header': `No information found.`, 'admin.billing.table.line_items.emptyTableDefault.message': `We couldn't find any billing information on file. Only administrators of a tenant are able to review billing information.`, - 'admin.billing.table.line_items.tooltip.download_pdf': `Download invoice PDF`, + 'admin.billing.table.line_items.tooltip.download_pdf': `Invoice`, 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, - 'admin.billing.table.line_items.tooltip.view_receipt': `View Receipt`, + 'admin.billing.table.line_items.tooltip.view_receipt': `Receipt`, 'admin.billing.table.line_items.tooltip.no_invoice': `No invoice available`, 'admin.billing.paymentMethods.header': `Payment Information`, From 216e938fa6adaa1792acc412df98c8ad11ca3ec7 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 00:50:26 -0400 Subject: [PATCH 11/16] Wrap the billing history Data / Tasks column, keep End Date on one line In narrow layouts the End Date wrapped to two lines because the combined Data / Tasks cell was set to nowrap and claimed the available width. Let the Data / Tasks cell wrap instead, and set the date cell to nowrap so the End Date stays on one line. --- src/components/tables/Billing/Rows.tsx | 2 +- src/components/tables/cells/billing/TimeStamp.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tables/Billing/Rows.tsx b/src/components/tables/Billing/Rows.tsx index 6663217f76..63bf1656a9 100644 --- a/src/components/tables/Billing/Rows.tsx +++ b/src/components/tables/Billing/Rows.tsx @@ -38,7 +38,7 @@ function Row({ row, isSelected }: RowProps) { tooltipMessageId="admin.billing.table.history.tooltip.date_end" /> - + + Date: Wed, 17 Jun 2026 16:09:31 -0400 Subject: [PATCH 12/16] billing UI refinements --- .../admin/Billing/AddPaymentMethod.tsx | 190 ++++++++---------- .../admin/Billing/PaymentMethodRow.tsx | 77 +++++-- .../admin/Billing/PaymentMethods.tsx | 62 ++++-- src/components/admin/Billing/index.tsx | 57 ++++-- src/components/tables/Billing/Rows.tsx | 21 +- src/components/tables/Billing/index.tsx | 5 +- .../tables/cells/billing/TimeStamp.tsx | 14 +- src/lang/en-US/AdminPage.ts | 3 +- 8 files changed, 249 insertions(+), 180 deletions(-) diff --git a/src/components/admin/Billing/AddPaymentMethod.tsx b/src/components/admin/Billing/AddPaymentMethod.tsx index baa1b352eb..4176186b70 100644 --- a/src/components/admin/Billing/AddPaymentMethod.tsx +++ b/src/components/admin/Billing/AddPaymentMethod.tsx @@ -1,10 +1,9 @@ import type { Stripe } from '@stripe/stripe-js'; -import { Box, Button, Dialog, DialogTitle, useTheme } from '@mui/material'; +import { Dialog, DialogTitle, useTheme } from '@mui/material'; import { usePostHog } from '@posthog/react'; import { Elements } from '@stripe/react-stripe-js'; -import { Plus } from 'iconoir-react'; import { useIntl } from 'react-intl'; import { setTenantPrimaryPaymentMethod } from 'src/api/billing'; @@ -25,7 +24,7 @@ interface Props { tenant: string; } -function AddPaymentMethod({ +export function AddPaymentMethodDialog({ onSuccess, show, setupIntentSecret, @@ -45,111 +44,90 @@ function AddPaymentMethod({ setupIntentSecret !== INTENT_SECRET_ERROR; return ( - <> - - - - - setOpen(false)} - data-private - > - - {intl.formatMessage({ - id: 'admin.billing.addPaymentMethods.title', - })} - - {enable ? ( - setOpen(false)} + data-private + > + + {intl.formatMessage({ + id: 'admin.billing.addPaymentMethods.title', + })} + + {enable ? ( + - {!tenant ? null : ( - { - if (id) { - await setTenantPrimaryPaymentMethod( - tenant, - id - ); + ...(isDark && { + rules: { + '.Input': { + ...flatField, + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + '.Tab': { + ...flatField, + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + '.Tab--focused': { + borderColor: theme.palette.primary.main, + }, + '.Block': { + ...flatField, + padding: '14px', + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + '.PickerItem': { + ...flatField, + backgroundColor: + stripePaymentFormFieldBackgroundDark, + }, + }, + }), + }, + }} + > + {!tenant ? null : ( + { + if (id) { + await setTenantPrimaryPaymentMethod( + tenant, + id + ); - fireGtmEvent('Payment_Entered', { - tenant, - }); + fireGtmEvent('Payment_Entered', { + tenant, + }); - postHog.capture('Payment_Entered', { - tenant, - }); - } - setOpen(false); - onSuccess(); - }} - onError={console.log} - /> - )} - - ) : null} - - + postHog.capture('Payment_Entered', { + tenant, + }); + } + setOpen(false); + onSuccess(); + }} + onError={console.log} + /> + )} + + ) : null} + ); } - -export default AddPaymentMethod; diff --git a/src/components/admin/Billing/PaymentMethodRow.tsx b/src/components/admin/Billing/PaymentMethodRow.tsx index ebd7797cb5..f8c0d6ccc1 100644 --- a/src/components/admin/Billing/PaymentMethodRow.tsx +++ b/src/components/admin/Billing/PaymentMethodRow.tsx @@ -1,6 +1,6 @@ -import { Button, TableCell, TableRow } from '@mui/material'; +import { Box, IconButton, TableCell, TableRow, Tooltip } from '@mui/material'; -import { Check } from 'iconoir-react'; +import { Star, StarSolid, Trash } from 'iconoir-react'; import AmexLogo from 'src/images/payment-methods/amex.png'; import DiscoverLogo from 'src/images/payment-methods/discover.png'; @@ -66,7 +66,13 @@ export const PaymentMethod = ({ primary, }: PaymentMethodProps) => { return ( - + {type === 'card' ? ( cardLogos[card.brand] ? ( @@ -89,22 +95,67 @@ export const PaymentMethod = ({ {type === 'card' ? ( <> - Expires {card.exp_month}/{card.exp_year} + {card.exp_month}/{card.exp_year} ) : ( us_bank_account.account_type )} - {primary ? : ''} - - {!primary ? ( - - ) : null} + + + {primary ? ( + + + + ) : ( + + + + )} + + + + + + + + ); diff --git a/src/components/admin/Billing/PaymentMethods.tsx b/src/components/admin/Billing/PaymentMethods.tsx index 5817100b7b..ed404dc186 100644 --- a/src/components/admin/Billing/PaymentMethods.tsx +++ b/src/components/admin/Billing/PaymentMethods.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Box, + Button, Stack, Table, TableBody, @@ -16,6 +17,7 @@ import { } from '@mui/material'; import { loadStripe } from '@stripe/stripe-js'; +import { Plus } from 'iconoir-react'; import { FormattedMessage } from 'react-intl'; import { @@ -24,7 +26,7 @@ import { getTenantPaymentMethods, setTenantPrimaryPaymentMethod, } from 'src/api/billing'; -import AddPaymentMethod from 'src/components/admin/Billing/AddPaymentMethod'; +import { AddPaymentMethodDialog } from 'src/components/admin/Billing/AddPaymentMethod'; import { PaymentMethod } from 'src/components/admin/Billing/PaymentMethodRow'; import { INTENT_SECRET_ERROR, @@ -42,7 +44,6 @@ const columns: TableColumns[] = [ { field: 'type', headerIntlKey: 'admin.billing.paymentMethods.table.label.cardType', - width: 200, }, { field: 'name', @@ -56,13 +57,10 @@ const columns: TableColumns[] = [ field: 'details', headerIntlKey: 'admin.billing.paymentMethods.table.label.details', }, - { - field: 'primary', - headerIntlKey: 'admin.billing.paymentMethods.table.label.primary', - }, { field: 'actions', - headerIntlKey: 'admin.billing.paymentMethods.table.label.actions', + align: 'center', + width: 100, }, ]; @@ -152,6 +150,10 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { } }, [serverErrored]); + const enable = + setupIntentSecret !== INTENT_SECRET_LOADING && + setupIntentSecret !== INTENT_SECRET_ERROR; + return ( {setupIntentSecret === INTENT_SECRET_ERROR ? ( @@ -164,7 +166,7 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { @@ -186,14 +188,33 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { {serverErrored ? null : ( - setRefreshCounter((r) => r + 1)} - stripePromise={stripePromise} - setupIntentSecret={setupIntentSecret} - /> + <> + + setRefreshCounter((r) => r + 1)} + stripePromise={stripePromise} + setupIntentSecret={setupIntentSecret} + /> + )} @@ -205,11 +226,7 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { ) : ( - +
{ > {columns.map((column, index) => ( @@ -261,7 +279,7 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { )) ) : ( - + diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index d57bbfbfde..82351ead24 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -1,6 +1,6 @@ import type { AdminBillingProps } from 'src/components/admin/Billing/types'; -import { Divider, Grid, Stack, Typography } from '@mui/material'; +import { Box, Divider, Stack, Typography } from '@mui/material'; import { ErrorBoundary } from 'react-error-boundary'; import { useIntl } from 'react-intl'; @@ -41,41 +41,65 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { <> - - + +
{intl.formatMessage({ id: 'admin.billing.header' })} - +
- - -
+ + + + + + + - - - - - - - + {/* Keep each value and its unit together so the column wraps + only at the space before the slash. */} + + {(row.extra?.processed_data_gb ?? 0).toFixed(1)} GB + {' '} + + / {row.extra?.task_usage_hours ?? 0} Hr +
diff --git a/src/components/tables/Billing/index.tsx b/src/components/tables/Billing/index.tsx index f0542a54b6..2eb8bbb60b 100644 --- a/src/components/tables/Billing/index.tsx +++ b/src/components/tables/Billing/index.tsx @@ -75,7 +75,9 @@ function BillingHistoryTable() { }, [allInvoices, currentPage, selectedInvoice]); return ( - +
setPage(newPage)} ActionsComponent={TablePaginationActions} + sx={{ mt: 'auto' }} /> ) : null} diff --git a/src/components/tables/cells/billing/TimeStamp.tsx b/src/components/tables/cells/billing/TimeStamp.tsx index cd145ba491..bd2c52f21a 100644 --- a/src/components/tables/cells/billing/TimeStamp.tsx +++ b/src/components/tables/cells/billing/TimeStamp.tsx @@ -17,7 +17,7 @@ function TimeStamp({ date, asLink, tooltipMessageId }: Props) { const strippedDate = stripTimeFromDate(date); return ( - + - + {/* Keep the month and day together so the year is the only + wrap point and drops to the next line in a narrow column. */} + + + , + {' '} + diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index e92dd2e26b..ea607f2643 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -68,7 +68,6 @@ export const AdminPage: Record = { 'admin.billing.table.history.label.date_end': `End Date`, 'admin.billing.table.history.label.usage': `Data / Tasks`, 'admin.billing.table.history.label.totalCost': `Total Cost`, - 'admin.billing.table.history.value.usage': `{volume} GB / {tasks} Hr`, 'admin.billing.table.history.tooltip.date_start': `This billing period began on {timestamp}`, 'admin.billing.table.history.tooltip.date_end': `This billing period ended on {timestamp}`, 'admin.billing.table.history.tooltip.dataVolume': `GB of data processed by tasks`, @@ -96,7 +95,7 @@ export const AdminPage: Record = { 'admin.billing.paymentMethods.table.label.cardType': `Type`, 'admin.billing.paymentMethods.table.label.name': `Name`, 'admin.billing.paymentMethods.table.label.lastFour': `Last 4 Digits`, - 'admin.billing.paymentMethods.table.label.details': `Details`, + 'admin.billing.paymentMethods.table.label.details': `Exp`, 'admin.billing.paymentMethods.table.label.primary': `Primary`, 'admin.billing.paymentMethods.table.label.actions': `Actions`, 'admin.billing.paymentMethods.table.emptyTableDefault.message': `No payment methods available.`, From f43794ea4402772d1111cb7b27c9c567e7352ce4 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 21:50:24 -0400 Subject: [PATCH 13/16] Migrate billing payment methods to GraphQL and rework the table UI Move the payment-methods admin flow off the Supabase `billing` edge function onto GraphQL. A new useBillingPaymentMethods hook reads the list via tenant.billing.paymentMethods and wraps the createBillingSetupIntent, setBillingPaymentMethod, and deleteBillingPaymentMethod mutations, so the Add dialog's SetupIntent secret and the set-primary/delete actions now go through GraphQL. The edge function is kept only for the multi-tenant trial-warning batch (getPaymentMethodsForTenants); the now-dead REST exports are removed. Table and UX changes: - Extract the Add Payment Method button out of the dialog; it is full-width and stacked under the header on narrow screens. - Replace the Primary column and the per-row Make Primary / Delete buttons with a single actions column: a star (filled = primary, outline-on-hover = make primary) and a hover-revealed red trash icon, each with a tooltip. - Set-primary updates optimistically and rolls back if the mutation fails; the list is only refetched on add/delete, not on a primary toggle. - Confirm before deleting a payment method, and reload the list before dismissing the dialog. - Center the Last 4 / Exp / actions columns and slim PaymentMethodProps to the fields actually rendered. Regenerate gql-types for the new operations. --- src/api/billing.ts | 32 +-- src/api/gql/billing.ts | 73 ++++++ .../admin/Billing/AddPaymentMethod.tsx | 12 +- .../Billing/DeletePaymentMethodDialog.tsx | 74 ++++++ .../admin/Billing/PaymentMethodRow.tsx | 11 - .../admin/Billing/PaymentMethods.tsx | 126 ++++------ src/gql-types/gql.ts | 24 ++ src/hooks/billing/useBillingPaymentMethods.ts | 223 ++++++++++++++++++ src/lang/en-US/AdminPage.ts | 4 +- 9 files changed, 447 insertions(+), 132 deletions(-) create mode 100644 src/components/admin/Billing/DeletePaymentMethodDialog.tsx create mode 100644 src/hooks/billing/useBillingPaymentMethods.ts diff --git a/src/api/billing.ts b/src/api/billing.ts index 9224c8176a..f3ed977842 100644 --- a/src/api/billing.ts +++ b/src/api/billing.ts @@ -5,42 +5,20 @@ import pLimit from 'p-limit'; import { FUNCTIONS, invokeSupabase } from 'src/services/supabase'; const OPERATIONS = { - SETUP_INTENT: 'setup-intent', GET_TENANT_PAYMENT_METHODS: 'get-tenant-payment-methods', - DELETE_TENANT_PAYMENT_METHODS: 'delete-tenant-payment-method', - SET_PRIMARY: 'set-tenant-primary-payment-method', }; -export const getSetupIntentSecret = (tenant: string) => { - return invokeSupabase(FUNCTIONS.BILLING, { - operation: OPERATIONS.SETUP_INTENT, - tenant, - }); -}; - -export const getTenantPaymentMethods = (tenant: string) => { +// The trial "missing payment method" warning fans this out across the user's +// tenants (see getPaymentMethodsForTenants) — the last edge-function caller. +// The billing admin page reads, sets, and deletes payment methods via GraphQL +// instead (src/api/gql/billing.ts). +const getTenantPaymentMethods = (tenant: string) => { return invokeSupabase(FUNCTIONS.BILLING, { operation: OPERATIONS.GET_TENANT_PAYMENT_METHODS, tenant, }); }; -export const deleteTenantPaymentMethod = (tenant: string, id: string) => { - return invokeSupabase(FUNCTIONS.BILLING, { - operation: OPERATIONS.DELETE_TENANT_PAYMENT_METHODS, - tenant, - id, - }); -}; - -export const setTenantPrimaryPaymentMethod = (tenant: string, id: string) => { - return invokeSupabase(FUNCTIONS.BILLING, { - operation: OPERATIONS.SET_PRIMARY, - tenant, - id, - }); -}; - export interface InvoiceLineItem { description: string; count: number; diff --git a/src/api/gql/billing.ts b/src/api/gql/billing.ts index ac984273de..3d70031b2b 100644 --- a/src/api/gql/billing.ts +++ b/src/api/gql/billing.ts @@ -34,3 +34,76 @@ export const TENANT_BILLING_INVOICES_QUERY = graphql(` } } `); + +// The full payment-method set for a tenant plus which one is primary. `last4` +// is a String in the schema (zero-padded), and only one of `card` / +// `usBankAccount` is populated per method depending on `type`. +export const TENANT_BILLING_PAYMENT_METHODS_QUERY = graphql(` + query TenantBillingPaymentMethods($tenant: String!) { + tenant(name: $tenant) { + billing { + primaryPaymentMethod { + id + } + paymentMethods { + id + type + billingDetails { + name + } + card { + brand + last4 + expMonth + expYear + } + usBankAccount { + bankName + last4 + } + } + } + } + } +`); + +// Creates the Stripe SetupIntent and returns its client secret; the secret is +// handed to the Stripe Elements form, which collects and confirms the card +// directly with Stripe. +export const CREATE_BILLING_SETUP_INTENT = graphql(` + mutation CreateBillingSetupIntent($tenant: String!) { + createBillingSetupIntent(tenant: $tenant) { + clientSecret + } + } +`); + +// Promotes an existing payment method to primary. The list is re-fetched after +// this resolves, so the payload only confirms the new primary. +export const SET_BILLING_PAYMENT_METHOD = graphql(` + mutation SetBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) { + setBillingPaymentMethod( + tenant: $tenant + paymentMethodId: $paymentMethodId + ) { + primaryPaymentMethod { + id + } + } + } +`); + +// Removes a payment method. As with set-primary, the list is re-fetched after +// this resolves. +export const DELETE_BILLING_PAYMENT_METHOD = graphql(` + mutation DeleteBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) { + deleteBillingPaymentMethod( + tenant: $tenant + paymentMethodId: $paymentMethodId + ) { + primaryPaymentMethod { + id + } + } + } +`); diff --git a/src/components/admin/Billing/AddPaymentMethod.tsx b/src/components/admin/Billing/AddPaymentMethod.tsx index 4176186b70..f003fde091 100644 --- a/src/components/admin/Billing/AddPaymentMethod.tsx +++ b/src/components/admin/Billing/AddPaymentMethod.tsx @@ -6,7 +6,6 @@ import { usePostHog } from '@posthog/react'; import { Elements } from '@stripe/react-stripe-js'; import { useIntl } from 'react-intl'; -import { setTenantPrimaryPaymentMethod } from 'src/api/billing'; import { PaymentForm } from 'src/components/admin/Billing/CapturePaymentMethod'; import { INTENT_SECRET_ERROR, @@ -19,7 +18,9 @@ interface Props { show: boolean; setupIntentSecret: string; setOpen: (val: boolean) => void; - onSuccess: () => void; + // Called with the newly added method's id once Stripe confirms it, so the + // parent can promote it to primary and re-fetch the list. + onSuccess: (paymentMethodId?: string | null) => void | Promise; stripePromise: Promise; tenant: string; } @@ -107,11 +108,6 @@ export function AddPaymentMethodDialog({ { if (id) { - await setTenantPrimaryPaymentMethod( - tenant, - id - ); - fireGtmEvent('Payment_Entered', { tenant, }); @@ -121,7 +117,7 @@ export function AddPaymentMethodDialog({ }); } setOpen(false); - onSuccess(); + await onSuccess(id); }} onError={console.log} /> diff --git a/src/components/admin/Billing/DeletePaymentMethodDialog.tsx b/src/components/admin/Billing/DeletePaymentMethodDialog.tsx new file mode 100644 index 0000000000..2061bf7679 --- /dev/null +++ b/src/components/admin/Billing/DeletePaymentMethodDialog.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +import { useIntl } from 'react-intl'; + +interface Props { + open: boolean; + onClose: () => void; + // Runs the delete; the dialog shows a spinner until it settles. + onConfirm: () => Promise; +} + +export function DeletePaymentMethodDialog({ open, onClose, onConfirm }: Props) { + const intl = useIntl(); + + const [processing, setProcessing] = useState(false); + + const handleConfirm = async () => { + setProcessing(true); + + try { + await onConfirm(); + } finally { + setProcessing(false); + } + }; + + return ( + + + {intl.formatMessage({ + id: 'admin.billing.paymentMethods.delete.confirmation.title', + })} + + + + + {intl.formatMessage({ + id: 'admin.billing.paymentMethods.delete.confirmation.message', + })} + + + + + + + + + + ); +} diff --git a/src/components/admin/Billing/PaymentMethodRow.tsx b/src/components/admin/Billing/PaymentMethodRow.tsx index f8c0d6ccc1..4b3c38a0ea 100644 --- a/src/components/admin/Billing/PaymentMethodRow.tsx +++ b/src/components/admin/Billing/PaymentMethodRow.tsx @@ -21,15 +21,6 @@ export interface PaymentMethodProps { id: string; type: 'card' | 'us_bank_account'; billing_details: { - address: { - city: string; - country: string; - line1: string; - line2: string; - postal_code: string; - state: string; - }; - email: string; name: string; }; card: { @@ -43,13 +34,11 @@ export interface PaymentMethodProps { | 'unionpay' | 'visa' | 'unknown'; - country: string; exp_month: number; exp_year: number; last4: number; }; us_bank_account: { - account_holder_type: 'individual' | 'company'; account_type: 'checking' | 'savings'; bank_name: string; last4: number; diff --git a/src/components/admin/Billing/PaymentMethods.tsx b/src/components/admin/Billing/PaymentMethods.tsx index ed404dc186..7e2961d500 100644 --- a/src/components/admin/Billing/PaymentMethods.tsx +++ b/src/components/admin/Billing/PaymentMethods.tsx @@ -20,13 +20,8 @@ import { loadStripe } from '@stripe/stripe-js'; import { Plus } from 'iconoir-react'; import { FormattedMessage } from 'react-intl'; -import { - deleteTenantPaymentMethod, - getSetupIntentSecret, - getTenantPaymentMethods, - setTenantPrimaryPaymentMethod, -} from 'src/api/billing'; import { AddPaymentMethodDialog } from 'src/components/admin/Billing/AddPaymentMethod'; +import { DeletePaymentMethodDialog } from 'src/components/admin/Billing/DeletePaymentMethodDialog'; import { PaymentMethod } from 'src/components/admin/Billing/PaymentMethodRow'; import { INTENT_SECRET_ERROR, @@ -34,6 +29,7 @@ import { } from 'src/components/admin/Billing/shared'; import AlertBox from 'src/components/shared/AlertBox'; import TableLoadingRows from 'src/components/tables/Loading'; +import { useBillingPaymentMethods } from 'src/hooks/billing/useBillingPaymentMethods'; import { logRocketEvent } from 'src/services/shared'; import { CustomEvents } from 'src/services/types'; import { useBillingStore } from 'src/stores/Billing'; @@ -76,73 +72,28 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { (state) => state.setPaymentMethodExists ); - const [refreshCounter, setRefreshCounter] = useState(0); - - const [setupIntentSecret, setSetupIntentSecret] = useState( - INTENT_SECRET_LOADING - ); const [newMethodOpen, setNewMethodOpen] = useState(showAddPayment ?? false); - const [methodsLoading, setMethodsLoading] = useState(false); - const [methods, setMethods] = useState([]); - const [defaultSource, setDefaultSource] = useState< - string | null | undefined - >(null); - - // These are two different iifes so this component loads just a _tiny bit_ faster - useEffect(() => { - void (async () => { - if (selectedTenant) { - const setupResponse = - await getSetupIntentSecret(selectedTenant); - - if (setupResponse.data?.intent_secret) { - setSetupIntentSecret(setupResponse.data.intent_secret); - } else { - setSetupIntentSecret(INTENT_SECRET_ERROR); - } - } - })(); - - void (async () => { - if (selectedTenant) { - setMethodsLoading(true); - - try { - // TODO (optimization): Add proper typing and error handling for this service call. The response assumes - // an unexpected shape when the service errors. The error property is null and the data property - // is an object with the following shape: { error: string; }. Consequently, an undefined value is passed - // to the setters below (unbeknownst to the compiler given the state typing defined above), causing the - // the component to lean on the ErrorBoundary wrapper for its display in the presence of an error. - - // TODO (store payment method info) we load this for the first 5 tenants so we should just pull that info - const methodsResponse = - await getTenantPaymentMethods(selectedTenant); + const [methodIdToDelete, setMethodIdToDelete] = useState( + null + ); - setMethods(methodsResponse.data?.payment_methods); - setDefaultSource(methodsResponse.data?.primary); - } finally { - setMethodsLoading(false); - } - } - })(); - }, [selectedTenant, refreshCounter]); + const { + methods, + primaryId, + isLoading, + serverErrored, + setupIntentSecret, + setPrimary, + deleteMethod, + refresh, + } = useBillingPaymentMethods(); useEffect(() => { - if (!methodsLoading) { + if (!isLoading) { setPaymentMethodExists(methods); } - }, [setPaymentMethodExists, methods, methodsLoading]); - - // TODO (optimization): Remove this temporary, hacky means of detecting when the payment methods service errs - // when proper error handling is in place. - const serverErrored = useMemo( - () => - !methodsLoading && - (typeof defaultSource === 'undefined' || - typeof methods === 'undefined'), - [defaultSource, methods, methodsLoading] - ); + }, [isLoading, methods, setPaymentMethodExists]); useEffect(() => { if (serverErrored) { @@ -210,7 +161,13 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { show={newMethodOpen} setOpen={setNewMethodOpen} tenant={selectedTenant} - onSuccess={() => setRefreshCounter((r) => r + 1)} + onSuccess={async (id) => { + if (id) { + await setPrimary(id); + } + // A card was added, so the list itself changed. + refresh(); + }} stripePromise={stripePromise} setupIntentSecret={setupIntentSecret} /> @@ -251,30 +208,20 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { - {!selectedTenant || methodsLoading ? ( + {!selectedTenant || isLoading ? ( - ) : methods && methods.length > 0 ? ( + ) : methods.length > 0 ? ( methods.map((method) => ( { - await deleteTenantPaymentMethod( - selectedTenant, - method.id - ); - setRefreshCounter((r) => r + 1); - }} - onPrimary={async () => { - await setTenantPrimaryPaymentMethod( - selectedTenant, - method.id - ); - setRefreshCounter((r) => r + 1); - }} + onDelete={() => + setMethodIdToDelete(method.id) + } + onPrimary={() => setPrimary(method.id)} key={method.id} {...method} - primary={method.id === defaultSource} + primary={method.id === primaryId} /> )) ) : ( @@ -292,6 +239,17 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => {
)} + + setMethodIdToDelete(null)} + onConfirm={async () => { + if (methodIdToDelete) { + await deleteMethod(methodIdToDelete); + } + setMethodIdToDelete(null); + }} + />
); }; diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index 50be49072c..b9680d8120 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -20,6 +20,10 @@ type Documents = { "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": typeof types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": typeof types.AlertTypeDocument, "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": typeof types.TenantBillingInvoicesDocument, + "\n query TenantBillingPaymentMethods($tenant: String!) {\n tenant(name: $tenant) {\n billing {\n primaryPaymentMethod {\n id\n }\n paymentMethods {\n id\n type\n billingDetails {\n name\n }\n card {\n brand\n last4\n expMonth\n expYear\n }\n usBankAccount {\n bankName\n last4\n }\n }\n }\n }\n }\n": typeof types.TenantBillingPaymentMethodsDocument, + "\n mutation CreateBillingSetupIntent($tenant: String!) {\n createBillingSetupIntent(tenant: $tenant) {\n clientSecret\n }\n }\n": typeof types.CreateBillingSetupIntentDocument, + "\n mutation SetBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n setBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n": typeof types.SetBillingPaymentMethodDocument, + "\n mutation DeleteBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n deleteBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n": typeof types.DeleteBillingPaymentMethodDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": typeof types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.DataPlanesDocument, @@ -49,6 +53,10 @@ const documents: Documents = { "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": types.AlertTypeDocument, "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": types.TenantBillingInvoicesDocument, + "\n query TenantBillingPaymentMethods($tenant: String!) {\n tenant(name: $tenant) {\n billing {\n primaryPaymentMethod {\n id\n }\n paymentMethods {\n id\n type\n billingDetails {\n name\n }\n card {\n brand\n last4\n expMonth\n expYear\n }\n usBankAccount {\n bankName\n last4\n }\n }\n }\n }\n }\n": types.TenantBillingPaymentMethodsDocument, + "\n mutation CreateBillingSetupIntent($tenant: String!) {\n createBillingSetupIntent(tenant: $tenant) {\n clientSecret\n }\n }\n": types.CreateBillingSetupIntentDocument, + "\n mutation SetBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n setBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n": types.SetBillingPaymentMethodDocument, + "\n mutation DeleteBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n deleteBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n": types.DeleteBillingPaymentMethodDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.DataPlanesDocument, @@ -110,6 +118,22 @@ export function graphql(source: "\n query AlertType {\n alertTypes {\n * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query TenantBillingPaymentMethods($tenant: String!) {\n tenant(name: $tenant) {\n billing {\n primaryPaymentMethod {\n id\n }\n paymentMethods {\n id\n type\n billingDetails {\n name\n }\n card {\n brand\n last4\n expMonth\n expYear\n }\n usBankAccount {\n bankName\n last4\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingPaymentMethods($tenant: String!) {\n tenant(name: $tenant) {\n billing {\n primaryPaymentMethod {\n id\n }\n paymentMethods {\n id\n type\n billingDetails {\n name\n }\n card {\n brand\n last4\n expMonth\n expYear\n }\n usBankAccount {\n bankName\n last4\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation CreateBillingSetupIntent($tenant: String!) {\n createBillingSetupIntent(tenant: $tenant) {\n clientSecret\n }\n }\n"): (typeof documents)["\n mutation CreateBillingSetupIntent($tenant: String!) {\n createBillingSetupIntent(tenant: $tenant) {\n clientSecret\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation SetBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n setBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation SetBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n setBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation DeleteBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n deleteBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation DeleteBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {\n deleteBillingPaymentMethod(\n tenant: $tenant\n paymentMethodId: $paymentMethodId\n ) {\n primaryPaymentMethod {\n id\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/hooks/billing/useBillingPaymentMethods.ts b/src/hooks/billing/useBillingPaymentMethods.ts new file mode 100644 index 0000000000..072f8f15a1 --- /dev/null +++ b/src/hooks/billing/useBillingPaymentMethods.ts @@ -0,0 +1,223 @@ +import type { PaymentMethodProps } from 'src/components/admin/Billing/PaymentMethodRow'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useClient, useMutation, useQuery } from 'urql'; + +import { + CREATE_BILLING_SETUP_INTENT, + DELETE_BILLING_PAYMENT_METHOD, + SET_BILLING_PAYMENT_METHOD, + TENANT_BILLING_PAYMENT_METHODS_QUERY, +} from 'src/api/gql/billing'; +import { + INTENT_SECRET_ERROR, + INTENT_SECRET_LOADING, +} from 'src/components/admin/Billing/shared'; +import { useTenantStore } from 'src/stores/Tenant'; + +// The data the billing table needs per method. `onDelete`, `onPrimary`, and +// `primary` are injected by the table when it renders each row. +export type BillingPaymentMethod = Omit< + PaymentMethodProps, + 'onDelete' | 'onPrimary' | 'primary' +>; + +interface PaymentMethodNode { + id: string; + type: string; + billingDetails: { name?: string | null }; + card?: { + brand?: string | null; + last4?: string | null; + expMonth: number; + expYear: number; + } | null; + usBankAccount?: { + bankName?: string | null; + last4?: string | null; + } | null; +} + +// The GQL node is camelCased and narrower than the legacy REST payload. Map +// what the schema provides into the shape the table renders. `account_type` is +// not exposed by the GQL API, so bank accounts render it empty. +const mapPaymentMethod = (node: PaymentMethodNode): BillingPaymentMethod => ({ + id: node.id, + type: node.type as PaymentMethodProps['type'], + billing_details: { + name: node.billingDetails?.name ?? '', + }, + card: { + brand: (node.card?.brand ?? + 'unknown') as PaymentMethodProps['card']['brand'], + exp_month: node.card?.expMonth ?? 0, + exp_year: node.card?.expYear ?? 0, + last4: node.card?.last4 ? Number(node.card.last4) : 0, + }, + us_bank_account: { + account_type: + '' as unknown as PaymentMethodProps['us_bank_account']['account_type'], + bank_name: node.usBankAccount?.bankName ?? '', + last4: node.usBankAccount?.last4 ? Number(node.usBankAccount.last4) : 0, + }, +}); + +export interface UseBillingPaymentMethodsResult { + methods: BillingPaymentMethod[]; + // The id of the primary method, for the table to flag the right row. + primaryId: string | null; + isLoading: boolean; + serverErrored: boolean; + // The Stripe SetupIntent client secret for the Add dialog, or one of the + // INTENT_SECRET_* sentinels while loading or on failure. + setupIntentSecret: string; + // Promote a method to primary; resolves to whether it succeeded. + setPrimary: (id: string) => Promise; + // Remove a method; resolves to whether it succeeded. + deleteMethod: (id: string) => Promise; + // Re-fetch the list (used after the Stripe form adds a card). + refresh: () => void; +} + +// Fetches and mutates the selected tenant's payment methods via GraphQL. urql +// keys its cache on the query variables, so switching tenants re-runs the query +// and the previous tenant's data is never shown. The set/delete mutations only +// confirm the new primary, so the list is explicitly re-fetched after each. +export function useBillingPaymentMethods(): UseBillingPaymentMethodsResult { + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const client = useClient(); + + const [{ data, fetching, error }] = useQuery({ + query: TENANT_BILLING_PAYMENT_METHODS_QUERY, + variables: { tenant: selectedTenant }, + pause: !selectedTenant, + }); + + const [, setBillingPaymentMethod] = useMutation(SET_BILLING_PAYMENT_METHOD); + const [, deleteBillingPaymentMethod] = useMutation( + DELETE_BILLING_PAYMENT_METHOD + ); + const [, createBillingSetupIntent] = useMutation( + CREATE_BILLING_SETUP_INTENT + ); + + // Re-fetch the list and resolve once it has loaded. Writing through the + // shared urql cache updates the live query above, so callers can await this + // before acting on the refreshed list (e.g. dismissing the delete dialog). + const refresh = useCallback( + () => + client + .query( + TENANT_BILLING_PAYMENT_METHODS_QUERY, + { tenant: selectedTenant }, + { requestPolicy: 'network-only' } + ) + .toPromise(), + [client, selectedTenant] + ); + + // Pre-warm the Stripe SetupIntent so the Add dialog has a client secret + // ready when it opens; re-created whenever the tenant changes. + const [setupIntentSecret, setSetupIntentSecret] = + useState(INTENT_SECRET_LOADING); + + useEffect(() => { + if (!selectedTenant) { + return; + } + + setSetupIntentSecret(INTENT_SECRET_LOADING); + + void createBillingSetupIntent({ tenant: selectedTenant }).then( + (result) => { + setSetupIntentSecret( + result.data?.createBillingSetupIntent.clientSecret ?? + INTENT_SECRET_ERROR + ); + }, + () => setSetupIntentSecret(INTENT_SECRET_ERROR) + ); + }, [createBillingSetupIntent, selectedTenant]); + + const methods = useMemo( + () => + (data?.tenant?.billing.paymentMethods ?? []).map(mapPaymentMethod), + [data] + ); + + // `primary` is a derived flag over a card list that selecting a primary + // does not change, so track it locally: the star updates immediately from + // the mutation result with no list refetch, and re-syncs whenever the + // query data changes (tenant switch, card added, card deleted). + const queryPrimaryId = + data?.tenant?.billing.primaryPaymentMethod?.id ?? null; + const [primaryId, setPrimaryId] = useState(null); + + useEffect(() => { + setPrimaryId(queryPrimaryId); + }, [queryPrimaryId]); + + const setPrimary = useCallback( + async (id: string) => { + // Move the star immediately, remembering the current primary so we + // can roll back if the mutation fails. + const previousPrimaryId = primaryId; + setPrimaryId(id); + + const result = await setBillingPaymentMethod({ + tenant: selectedTenant, + paymentMethodId: id, + }); + + if (result.error) { + setPrimaryId(previousPrimaryId); + + return false; + } + + // Reconcile with the primary the server confirmed (normally `id`). + setPrimaryId( + result.data?.setBillingPaymentMethod.primaryPaymentMethod?.id ?? + id + ); + + return true; + }, + [primaryId, selectedTenant, setBillingPaymentMethod] + ); + + const deleteMethod = useCallback( + async (id: string) => { + const result = await deleteBillingPaymentMethod({ + tenant: selectedTenant, + paymentMethodId: id, + }); + + if (result.error) { + return false; + } + + // Reload before resolving so the caller dismisses the dialog only + // once the deleted row is gone from the list. + await refresh(); + + return true; + }, + [deleteBillingPaymentMethod, refresh, selectedTenant] + ); + + return { + methods, + primaryId, + // Only the initial load shows the table skeleton; background refetches + // (after a delete, or urql cache revalidation) keep the current rows + // on screen so the table never flashes empty. + isLoading: fetching && !data, + serverErrored: Boolean(error), + setupIntentSecret, + setPrimary, + deleteMethod, + refresh, + }; +} diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index ea607f2643..a5fd4df67a 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -96,9 +96,9 @@ export const AdminPage: Record = { 'admin.billing.paymentMethods.table.label.name': `Name`, 'admin.billing.paymentMethods.table.label.lastFour': `Last 4 Digits`, 'admin.billing.paymentMethods.table.label.details': `Exp`, - 'admin.billing.paymentMethods.table.label.primary': `Primary`, - 'admin.billing.paymentMethods.table.label.actions': `Actions`, 'admin.billing.paymentMethods.table.emptyTableDefault.message': `No payment methods available.`, + 'admin.billing.paymentMethods.delete.confirmation.title': `Delete payment method?`, + 'admin.billing.paymentMethods.delete.confirmation.message': `This payment method will be removed from your account. This action cannot be undone.`, 'admin.billing.addPaymentMethods.title': `Add a payment method`, 'admin.billing.addPaymentMethods.stripeLoadError': `Unable to load the forms from Stripe. ${Errors['error.tryAgain']}`, From dd1fd46bc6b2d6ce6aaf6822c354dc5ea677f5c4 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 22:36:08 -0400 Subject: [PATCH 14/16] payment methods --- .../admin/Billing/PaymentMethodRow.tsx | 35 ++++++++++++++----- src/hooks/billing/useBillingPaymentMethods.ts | 11 +++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/components/admin/Billing/PaymentMethodRow.tsx b/src/components/admin/Billing/PaymentMethodRow.tsx index 4b3c38a0ea..16a6c92645 100644 --- a/src/components/admin/Billing/PaymentMethodRow.tsx +++ b/src/components/admin/Billing/PaymentMethodRow.tsx @@ -1,6 +1,6 @@ import { Box, IconButton, TableCell, TableRow, Tooltip } from '@mui/material'; -import { Star, StarSolid, Trash } from 'iconoir-react'; +import { Bank, CreditCard, Star, StarSolid, Trash } from 'iconoir-react'; import AmexLogo from 'src/images/payment-methods/amex.png'; import DiscoverLogo from 'src/images/payment-methods/discover.png'; @@ -14,12 +14,20 @@ const cardLogos: Record = { mastercard: MastercardLogo, }; +// Stripe types other than card / us_bank_account (link, cashapp, amazon_pay, …) +// get a generic row labelled with the humanized type. +const humanizePaymentType = (type: string) => + type + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + export interface PaymentMethodProps { onDelete(): void; onPrimary(): void; primary: boolean; id: string; - type: 'card' | 'us_bank_account'; + type: string; billing_details: { name: string; }; @@ -36,12 +44,11 @@ export interface PaymentMethodProps { | 'unknown'; exp_month: number; exp_year: number; - last4: number; + last4: string; }; us_bank_account: { - account_type: 'checking' | 'savings'; bank_name: string; - last4: number; + last4: string; }; } @@ -73,13 +80,25 @@ export const PaymentMethod = ({ ) : ( card.brand ) + ) : type === 'us_bank_account' ? ( + + + {us_bank_account.bank_name} + ) : ( - us_bank_account.bank_name + + + {humanizePaymentType(type)} + )}
{billing_details.name} - {type === 'card' ? card.last4 : us_bank_account.last4} + {type === 'card' + ? card.last4 + : type === 'us_bank_account' + ? us_bank_account.last4 + : '—'} {type === 'card' ? ( @@ -87,7 +106,7 @@ export const PaymentMethod = ({ {card.exp_month}/{card.exp_year} ) : ( - us_bank_account.account_type + '—' )} diff --git a/src/hooks/billing/useBillingPaymentMethods.ts b/src/hooks/billing/useBillingPaymentMethods.ts index 072f8f15a1..62f27f3454 100644 --- a/src/hooks/billing/useBillingPaymentMethods.ts +++ b/src/hooks/billing/useBillingPaymentMethods.ts @@ -40,11 +40,10 @@ interface PaymentMethodNode { } // The GQL node is camelCased and narrower than the legacy REST payload. Map -// what the schema provides into the shape the table renders. `account_type` is -// not exposed by the GQL API, so bank accounts render it empty. +// what the schema provides into the shape the table renders. const mapPaymentMethod = (node: PaymentMethodNode): BillingPaymentMethod => ({ id: node.id, - type: node.type as PaymentMethodProps['type'], + type: node.type, billing_details: { name: node.billingDetails?.name ?? '', }, @@ -53,13 +52,11 @@ const mapPaymentMethod = (node: PaymentMethodNode): BillingPaymentMethod => ({ 'unknown') as PaymentMethodProps['card']['brand'], exp_month: node.card?.expMonth ?? 0, exp_year: node.card?.expYear ?? 0, - last4: node.card?.last4 ? Number(node.card.last4) : 0, + last4: node.card?.last4 ?? '', }, us_bank_account: { - account_type: - '' as unknown as PaymentMethodProps['us_bank_account']['account_type'], bank_name: node.usBankAccount?.bankName ?? '', - last4: node.usBankAccount?.last4 ? Number(node.usBankAccount.last4) : 0, + last4: node.usBankAccount?.last4 ?? '', }, }); From 6a0159b1664fd96bd2696a40130f25eb319d475d Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 23:26:20 -0400 Subject: [PATCH 15/16] codegen --- src/gql-types/graphql.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/gql-types/graphql.ts b/src/gql-types/graphql.ts index 00e609867d..4306dec253 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -1970,6 +1970,36 @@ export type TenantBillingInvoicesQueryVariables = Exact<{ export type TenantBillingInvoicesQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', invoices: { __typename?: 'InvoiceConnection', nodes: Array<{ __typename?: 'Invoice', dateStart: string, dateEnd: string, invoiceType: InvoiceType, subtotal: number, lineItems: any, extra: any, status?: string | null, invoicePdf?: string | null, hostedInvoiceUrl?: string | null, paymentDetails?: { __typename?: 'InvoicePaymentDetails', status: ChargeStatus, receiptUrl?: string | null } | null }> } } } | null }; +export type TenantBillingPaymentMethodsQueryVariables = Exact<{ + tenant: Scalars['String']['input']; +}>; + + +export type TenantBillingPaymentMethodsQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', primaryPaymentMethod?: { __typename?: 'PaymentMethod', id: string } | null, paymentMethods: Array<{ __typename?: 'PaymentMethod', id: string, type: string, billingDetails: { __typename?: 'PaymentMethodBillingDetails', name?: string | null }, card?: { __typename?: 'CardPaymentMethodDetails', brand?: string | null, last4?: string | null, expMonth: number, expYear: number } | null, usBankAccount?: { __typename?: 'UsBankAccountPaymentMethodDetails', bankName?: string | null, last4?: string | null } | null }> } } | null }; + +export type CreateBillingSetupIntentMutationVariables = Exact<{ + tenant: Scalars['String']['input']; +}>; + + +export type CreateBillingSetupIntentMutation = { __typename?: 'MutationRoot', createBillingSetupIntent: { __typename?: 'CreateBillingSetupIntentPayload', clientSecret: string } }; + +export type SetBillingPaymentMethodMutationVariables = Exact<{ + tenant: Scalars['String']['input']; + paymentMethodId: Scalars['String']['input']; +}>; + + +export type SetBillingPaymentMethodMutation = { __typename?: 'MutationRoot', setBillingPaymentMethod: { __typename?: 'BillingPaymentMethodPayload', primaryPaymentMethod?: { __typename?: 'PaymentMethod', id: string } | null } }; + +export type DeleteBillingPaymentMethodMutationVariables = Exact<{ + tenant: Scalars['String']['input']; + paymentMethodId: Scalars['String']['input']; +}>; + + +export type DeleteBillingPaymentMethodMutation = { __typename?: 'MutationRoot', deleteBillingPaymentMethod: { __typename?: 'BillingPaymentMethodPayload', primaryPaymentMethod?: { __typename?: 'PaymentMethod', id: string } | null } }; + export type ConnectorsGridQueryVariables = Exact<{ filter?: InputMaybe; after?: InputMaybe; @@ -2137,6 +2167,10 @@ export const AlertSubscriptionsDocument = {"kind":"Document","definitions":[{"ki export const UpdateAlertSubscriptionMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAlertSubscriptionMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AlertType"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAlertSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"alertTypes"},"value":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const AlertTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertType"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"isDefault"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}}]}}]}}]} as unknown as DocumentNode; export const TenantBillingInvoicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingInvoices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invoices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dateStart"}},{"kind":"Field","name":{"kind":"Name","value":"dateEnd"}},{"kind":"Field","name":{"kind":"Name","value":"invoiceType"}},{"kind":"Field","name":{"kind":"Name","value":"subtotal"}},{"kind":"Field","name":{"kind":"Name","value":"lineItems"}},{"kind":"Field","name":{"kind":"Name","value":"extra"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"invoicePdf"}},{"kind":"Field","name":{"kind":"Name","value":"hostedInvoiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"paymentDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"receiptUrl"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const TenantBillingPaymentMethodsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingPaymentMethods"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"primaryPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethods"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"billingDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"card"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"brand"}},{"kind":"Field","name":{"kind":"Name","value":"last4"}},{"kind":"Field","name":{"kind":"Name","value":"expMonth"}},{"kind":"Field","name":{"kind":"Name","value":"expYear"}}]}},{"kind":"Field","name":{"kind":"Name","value":"usBankAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bankName"}},{"kind":"Field","name":{"kind":"Name","value":"last4"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateBillingSetupIntentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateBillingSetupIntent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createBillingSetupIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenant"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode; +export const SetBillingPaymentMethodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetBillingPaymentMethod"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setBillingPaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenant"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}},{"kind":"Argument","name":{"kind":"Name","value":"paymentMethodId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"primaryPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteBillingPaymentMethodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteBillingPaymentMethod"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteBillingPaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tenant"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}},{"kind":"Argument","name":{"kind":"Name","value":"paymentMethodId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"primaryPaymentMethod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorsGridDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorsGrid"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectorsFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"500"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"recommended"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"defaultSpec"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorTagDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorTagData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connector"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"imageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"connectorSpec"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fullImageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"defaultCaptureInterval"}},{"kind":"Field","name":{"kind":"Name","value":"disableBackfill"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"endpointSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"resourceSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]} as unknown as DocumentNode; export const DataPlanesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DataPlanes"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataPlanes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"cloudProvider"}},{"kind":"Field","name":{"kind":"Name","value":"region"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"fqdn"}},{"kind":"Field","name":{"kind":"Name","value":"cidrBlocks"}},{"kind":"Field","name":{"kind":"Name","value":"awsIamUserArn"}},{"kind":"Field","name":{"kind":"Name","value":"gcpServiceAccountEmail"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationClientId"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationName"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; From c5ae2561fdb6c08fb238551f12274afd77b44f4d Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 18 Jun 2026 23:14:58 -0400 Subject: [PATCH 16/16] Inline react-intl strings in billing UI Replace FormattedMessage/useIntl usage across the billing admin page, graphs, and invoice/payment-method tables with inline English copy, and remove the message keys that this orphans from AdminPage.ts. Swap intl.formatDate/FormattedDate for date-fns format and intl.formatNumber for native toLocaleString, dropping react-intl from the graph, totals, and timestamp components entirely. Reproduce the taskHoursByMonth ICU plural as a JS ternary. Unwind the TimeStamp tooltipMessageId indirection into a getTooltip callback and the PaymentMethods column headers into literal labels. Table column headers and empty-state messages rendered by the shared EntityTableHeader/EntityTableBody still use message keys, since those components are outside this change and shared across many tables. --- .../admin/Billing/AddPaymentMethod.tsx | 8 +--- .../Billing/DeletePaymentMethodDialog.tsx | 19 +++----- src/components/admin/Billing/LoadError.tsx | 11 ++--- .../admin/Billing/PaymentMethods.tsx | 34 +++++++------- .../admin/Billing/PricingTierDetails.tsx | 24 ++++------ src/components/admin/Billing/index.tsx | 34 ++++---------- .../graphs/TaskHoursByMonthGraph.tsx | 28 +++++------- src/components/graphs/UsageByMonthGraph.tsx | 45 +++++-------------- src/components/graphs/states/Wrapper.tsx | 16 ++----- .../tables/BillLineItems/TotalLines.tsx | 7 +-- src/components/tables/BillLineItems/index.tsx | 31 +++---------- src/components/tables/Billing/Rows.tsx | 4 +- src/components/tables/Billing/index.tsx | 12 +---- .../tables/cells/billing/TimeStamp.tsx | 32 ++++--------- src/lang/en-US/AdminPage.ts | 38 ---------------- 15 files changed, 88 insertions(+), 255 deletions(-) diff --git a/src/components/admin/Billing/AddPaymentMethod.tsx b/src/components/admin/Billing/AddPaymentMethod.tsx index f003fde091..dc14d5bd38 100644 --- a/src/components/admin/Billing/AddPaymentMethod.tsx +++ b/src/components/admin/Billing/AddPaymentMethod.tsx @@ -4,7 +4,6 @@ import { Dialog, DialogTitle, useTheme } from '@mui/material'; import { usePostHog } from '@posthog/react'; import { Elements } from '@stripe/react-stripe-js'; -import { useIntl } from 'react-intl'; import { PaymentForm } from 'src/components/admin/Billing/CapturePaymentMethod'; import { @@ -33,7 +32,6 @@ export function AddPaymentMethodDialog({ stripePromise, tenant, }: Props) { - const intl = useIntl(); const postHog = usePostHog(); const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; @@ -53,11 +51,7 @@ export function AddPaymentMethodDialog({ onClose={() => setOpen(false)} data-private > - - {intl.formatMessage({ - id: 'admin.billing.addPaymentMethods.title', - })} - + Add a payment method {enable ? ( void; @@ -19,8 +17,6 @@ interface Props { } export function DeletePaymentMethodDialog({ open, onClose, onConfirm }: Props) { - const intl = useIntl(); - const [processing, setProcessing] = useState(false); const handleConfirm = async () => { @@ -41,23 +37,18 @@ export function DeletePaymentMethodDialog({ open, onClose, onConfirm }: Props) { maxWidth="xs" fullWidth > - - {intl.formatMessage({ - id: 'admin.billing.paymentMethods.delete.confirmation.title', - })} - + Delete payment method? - {intl.formatMessage({ - id: 'admin.billing.paymentMethods.delete.confirmation.message', - })} + This payment method will be removed from your account. This + action cannot be undone. diff --git a/src/components/admin/Billing/LoadError.tsx b/src/components/admin/Billing/LoadError.tsx index d9d07e3b77..c4c9496d59 100644 --- a/src/components/admin/Billing/LoadError.tsx +++ b/src/components/admin/Billing/LoadError.tsx @@ -1,5 +1,3 @@ -import { FormattedMessage } from 'react-intl'; - import AlertBox from 'src/components/shared/AlertBox'; import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; @@ -11,12 +9,9 @@ function BillingLoadError() { } return ( - } - > - + + There was an error fetching your billing details. Try again and if + the issue persists please contact support. ); } diff --git a/src/components/admin/Billing/PaymentMethods.tsx b/src/components/admin/Billing/PaymentMethods.tsx index 7e2961d500..28aae0a36f 100644 --- a/src/components/admin/Billing/PaymentMethods.tsx +++ b/src/components/admin/Billing/PaymentMethods.tsx @@ -18,7 +18,6 @@ import { import { loadStripe } from '@stripe/stripe-js'; import { Plus } from 'iconoir-react'; -import { FormattedMessage } from 'react-intl'; import { AddPaymentMethodDialog } from 'src/components/admin/Billing/AddPaymentMethod'; import { DeletePaymentMethodDialog } from 'src/components/admin/Billing/DeletePaymentMethodDialog'; @@ -36,22 +35,22 @@ import { useBillingStore } from 'src/stores/Billing'; import { useTenantStore } from 'src/stores/Tenant'; import { getColumnKeyList } from 'src/utils/table-utils'; -const columns: TableColumns[] = [ +const columns: (TableColumns & { header?: string })[] = [ { field: 'type', - headerIntlKey: 'admin.billing.paymentMethods.table.label.cardType', + header: 'Type', }, { field: 'name', - headerIntlKey: 'admin.billing.paymentMethods.table.label.name', + header: 'Name', }, { field: 'last_four_digits', - headerIntlKey: 'admin.billing.paymentMethods.table.label.lastFour', + header: 'Last 4 Digits', }, { field: 'details', - headerIntlKey: 'admin.billing.paymentMethods.table.label.details', + header: 'Exp', }, { field: 'actions', @@ -110,7 +109,9 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { {setupIntentSecret === INTENT_SECRET_ERROR ? ( - + There was an issue attempting to get a token from + Stripe. You cannot currently add a payment method. Try + again and if the issue persists please contact support. ) : null} @@ -128,12 +129,14 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { fontWeight: '400', }} > - + Payment Information
{serverErrored ? null : ( - + { + "Enter your payment information. You won't be charged until your account usage exceeds free tier limits." + } )}
@@ -155,7 +158,7 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { }} variant="contained" > - + Add Payment Method { {serverErrored ? ( - + There was an error connecting with our payment provider. + Please try again later. ) : ( @@ -197,11 +201,7 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { key={`${column.field}-${index}`} width={column.width ?? 'auto'} > - {column.headerIntlKey ? ( - - ) : null} + {column.header ?? null} ))} @@ -230,7 +230,7 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { - + No payment methods available. diff --git a/src/components/admin/Billing/PricingTierDetails.tsx b/src/components/admin/Billing/PricingTierDetails.tsx index 8102906886..f46e16228b 100644 --- a/src/components/admin/Billing/PricingTierDetails.tsx +++ b/src/components/admin/Billing/PricingTierDetails.tsx @@ -2,8 +2,6 @@ import { useMemo } from 'react'; import { Skeleton, Typography } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; - import { useTenantUsesExternalPayment } from 'src/context/fetcher/TenantBillingDetails'; import { useBillingStore } from 'src/stores/Billing'; import { useTenantStore } from 'src/stores/Tenant'; @@ -17,40 +15,34 @@ function PricingTierDetails() { (state) => state.paymentMethodExists ); - const messageId = useMemo(() => { + const message = useMemo(() => { if (externalPaymentMethod) { if (marketPlaceProvider === 'gcp') { - return 'admin.billing.message.external.gcp'; + return 'GCP Marketplace'; } if (marketPlaceProvider === 'aws') { - return 'admin.billing.message.external.aws'; + return 'AWS Marketplace'; } - return 'admin.billing.message.external'; + return ' '; } if (paymentMethodExists) { - return 'admin.billing.message.paidTier'; + return 'Cloud tier'; } - return 'admin.billing.message.freeTier'; + return 'The free tier lets you try Estuary with up to 2 tasks and 10GB per month without entering a credit card. Usage beyond these limits automatically starts a 30 day free trial.'; }, [externalPaymentMethod, marketPlaceProvider, paymentMethodExists]); if (typeof paymentMethodExists !== 'boolean') { return ( - - - + {message} ); } else { - return ( - - - - ); + return {message}; } } diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index 82351ead24..cc886df0ec 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -3,7 +3,6 @@ import type { AdminBillingProps } from 'src/components/admin/Billing/types'; import { Box, Divider, Stack, Typography } from '@mui/material'; import { ErrorBoundary } from 'react-error-boundary'; -import { useIntl } from 'react-intl'; import { authenticatedRoutes } from 'src/app/routes'; import DateRange from 'src/components/admin/Billing/DateRange'; @@ -33,8 +32,6 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { headerLink: 'https://www.estuary.dev/pricing/', }); - const intl = useIntl(); - const { isLoading, selectedInvoice } = useBillingInvoices(); return ( @@ -52,7 +49,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { >
- {intl.formatMessage({ id: 'admin.billing.header' })} + Billing @@ -72,9 +69,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { @@ -90,9 +85,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { }} > @@ -103,23 +96,17 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { height={TOTAL_CARD_HEIGHT} message={ isLoading ? ( - intl.formatMessage({ - id: 'admin.billing.label.lineItems.loading', - }) + 'Loading your bill' ) : selectedInvoice ? ( <> - {intl.formatMessage({ - id: 'admin.billing.label.lineItems', - })} + Your bill for: ) : ( - intl.formatMessage({ - id: 'admin.billing.label.lineItems.empty', - }) + 'No bill to display' ) } > @@ -150,15 +137,12 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { fontWeight: '400', }} > - {intl.formatMessage({ - id: 'admin.billing.paymentMethods.header', - })} + Payment Information - {intl.formatMessage({ - id: 'admin.billing.error.paymentMethodsError', - })} + There was an error connecting with our + payment provider. Please try again later. diff --git a/src/components/graphs/TaskHoursByMonthGraph.tsx b/src/components/graphs/TaskHoursByMonthGraph.tsx index d53e769a95..c0b1303a2b 100644 --- a/src/components/graphs/TaskHoursByMonthGraph.tsx +++ b/src/components/graphs/TaskHoursByMonthGraph.tsx @@ -7,6 +7,7 @@ import { useTheme } from '@mui/material'; import { eachMonthOfInterval, + format, isWithinInterval, startOfMonth, sub, @@ -20,7 +21,6 @@ import { import * as echarts from 'echarts/core'; import { UniversalTransition } from 'echarts/features'; import { CanvasRenderer } from 'echarts/renderers'; -import { useIntl } from 'react-intl'; import { useUnmount } from 'react-use'; import { @@ -36,7 +36,6 @@ const chartContainerId = 'task-hours-by-month'; function TaskHoursByMonthGraph() { const theme = useTheme(); - const intl = useIntl(); const tooltipConfig = useTooltipConfig(); const { invoices, isLoading } = useBillingInvoices(); @@ -52,8 +51,8 @@ function TaskHoursByMonthGraph() { return eachMonthOfInterval({ start: startDate, end: today, - }).map((date) => intl.formatDate(date, { month: 'short' })); - }, [intl, today]); + }).map((date) => format(date, 'MMM')); + }, [today]); const seriesConfig: SeriesConfig[] = useMemo(() => { const startDate = startOfMonth(sub(today, { months: 5 })); @@ -74,14 +73,14 @@ function TaskHoursByMonthGraph() { }) .map(({ date_start, extra }) => { const billedMonth = stripTimeFromDate(date_start); - const month = intl.formatDate(billedMonth, { month: 'short' }); + const month = format(billedMonth, 'MMM'); return { seriesName: date_start, data: [[month, extra?.task_usage_hours ?? 0]], }; }); - }, [invoices, intl, today]); + }, [invoices, today]); useEffect(() => { if (!isLoading && invoices.length > 0) { @@ -131,12 +130,9 @@ function TaskHoursByMonthGraph() { tooltipConfigs.forEach((config) => { const taskCount = config.value[1]; - const formattedValue = intl.formatMessage( - { - id: 'admin.billing.graph.taskHoursByMonth.formatValue', - }, - { taskUsage: taskCount } - ); + const formattedValue = `${taskCount} ${ + taskCount === 1 ? 'Hour' : 'Hours' + }`; const tooltipItem = getTooltipItem( config.marker, @@ -152,12 +148,9 @@ function TaskHoursByMonthGraph() { const billedMonth = stripTimeFromDate(date_start); - return intl.formatDate( + return format( billedMonth, - { - month: 'short', - year: 'numeric', - } + 'MMM yyyy' ); }) .find((date) => @@ -190,7 +183,6 @@ function TaskHoursByMonthGraph() { }, [ invoices, isLoading, - intl, months, myChart, seriesConfig, diff --git a/src/components/graphs/UsageByMonthGraph.tsx b/src/components/graphs/UsageByMonthGraph.tsx index 20d6624c4a..74d1651af9 100644 --- a/src/components/graphs/UsageByMonthGraph.tsx +++ b/src/components/graphs/UsageByMonthGraph.tsx @@ -8,6 +8,7 @@ import { useTheme } from '@mui/material'; import { eachMonthOfInterval, endOfMonth, + format, isWithinInterval, startOfMonth, sub, @@ -22,7 +23,6 @@ import { import * as echarts from 'echarts/core'; import { UniversalTransition } from 'echarts/features'; import { CanvasRenderer } from 'echarts/renderers'; -import { useIntl } from 'react-intl'; import useLegendConfig from 'src/components/graphs/useLegendConfig'; import useTooltipConfig from 'src/components/graphs/useTooltipConfig'; @@ -36,7 +36,6 @@ const itemStyle = { borderRadius: [4, 4, 0, 0] }; function UsageByMonthGraph() { const theme = useTheme(); - const intl = useIntl(); const tooltipConfig = useTooltipConfig(); const legendConfig = useLegendConfig([{ name: 'Data' }, { name: 'Hours' }]); @@ -52,8 +51,8 @@ function UsageByMonthGraph() { return eachMonthOfInterval({ start: startDate, end: today, - }).map((date) => intl.formatDate(date, { month: 'short' })); - }, [intl, today]); + }).map((date) => format(date, 'MMM')); + }, [today]); const seriesConfigs = useMemo(() => { const startDate = startOfMonth(sub(today, { months: 5 })); @@ -77,7 +76,7 @@ function UsageByMonthGraph() { const data_series = filteredHistory.flatMap(({ date_start, extra }) => { const billedMonth = stripTimeFromDate(date_start); - const month = intl.formatDate(billedMonth, { month: 'short' }); + const month = format(billedMonth, 'MMM'); return { month, data: extra?.processed_data_gb ?? 0 }; }); @@ -85,14 +84,14 @@ function UsageByMonthGraph() { const hours_series = filteredHistory.flatMap( ({ date_start, extra }) => { const billedMonth = stripTimeFromDate(date_start); - const month = intl.formatDate(billedMonth, { month: 'short' }); + const month = format(billedMonth, 'MMM'); return { month, data: extra?.task_usage_hours ?? 0 }; } ); return { data: data_series, hours: hours_series }; - }, [invoices, intl, today]); + }, [invoices, today]); useEffect(() => { if (!isLoading && invoices.length > 0) { @@ -126,7 +125,6 @@ function UsageByMonthGraph() { }, [ invoices, isLoading, - intl, legendConfig, months, myChart, @@ -147,9 +145,7 @@ function UsageByMonthGraph() { { type: 'value', axisLabel: { - formatter: intl.messages[ - 'admin.billing.graph.usageByMonth.dataFormatter' - ] as string, + formatter: '{value} GB', color: eChartsColors.medium[0], fontSize: 14, fontWeight: 'bold', @@ -162,9 +158,7 @@ function UsageByMonthGraph() { { type: 'value', axisLabel: { - formatter: intl.messages[ - 'admin.billing.graph.usageByMonth.hoursFormatter' - ] as string, + formatter: '{value} hours', color: eChartsColors.medium[1], fontSize: 14, fontWeight: 'bold', @@ -184,16 +178,7 @@ function UsageByMonthGraph() { ]), itemStyle, tooltip: { - valueFormatter: (value) => { - return intl.formatMessage( - { - id: 'admin.billing.graph.usageByMonth.dataFormatter', - }, - { - value: String(value), - } - ); - }, + valueFormatter: (value) => `${value} GB`, }, }, { @@ -207,16 +192,7 @@ function UsageByMonthGraph() { ]), itemStyle, tooltip: { - valueFormatter: (value) => { - return intl.formatMessage( - { - id: 'admin.billing.graph.usageByMonth.hoursFormatter', - }, - { - value: String(value), - } - ); - }, + valueFormatter: (value) => `${value} hours`, }, yAxisIndex: 1, }, @@ -240,7 +216,6 @@ function UsageByMonthGraph() { myChart?.setOption(option); }, [ - intl, legendConfig, months, myChart, diff --git a/src/components/graphs/states/Wrapper.tsx b/src/components/graphs/states/Wrapper.tsx index f81e410f4e..b9dac295b9 100644 --- a/src/components/graphs/states/Wrapper.tsx +++ b/src/components/graphs/states/Wrapper.tsx @@ -2,8 +2,6 @@ import type { BaseComponentProps } from 'src/types'; import { Box } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; - import EmptyGraphState from 'src/components/graphs/states/Empty'; import GraphLoadingState from 'src/components/graphs/states/Loading'; import { eChartsTooltipSX } from 'src/components/graphs/tooltips'; @@ -20,12 +18,8 @@ function GraphStateWrapper({ children }: BaseComponentProps) { if (networkFailed) { return ( - } - message={ - - } + header="There was a network issue." + message="Please check your internet connection and reload the application." /> ); } @@ -34,11 +28,7 @@ function GraphStateWrapper({ children }: BaseComponentProps) { return hasLength(billingHistory) ? ( {children} ) : ( - - } - /> + ); } diff --git a/src/components/tables/BillLineItems/TotalLines.tsx b/src/components/tables/BillLineItems/TotalLines.tsx index 62b22e4c93..a5a2f210a2 100644 --- a/src/components/tables/BillLineItems/TotalLines.tsx +++ b/src/components/tables/BillLineItems/TotalLines.tsx @@ -2,10 +2,7 @@ import type { Invoice } from 'src/api/billing'; import { Box, Divider, Typography } from '@mui/material'; -import { FormattedMessage, useIntl } from 'react-intl'; - function TotalLines({ invoice }: { invoice: Invoice }) { - const intl = useIntl(); return ( - + Total Due - {intl.formatNumber(invoice.subtotal / 100, { + {(invoice.subtotal / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD', })} diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index 9cb3613e57..1eb5800993 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -11,7 +11,6 @@ import { } from '@mui/material'; import { CreditCard, Download } from 'iconoir-react'; -import { useIntl } from 'react-intl'; import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared'; import Rows from 'src/components/tables/BillLineItems/Rows'; @@ -46,8 +45,6 @@ export const columns: TableColumns[] = [ // TODO (billing): Use the getStatsForBillingHistoryTable query function as the primary source of data for this view // when a database table containing historic billing data is available. function BillingLineItemsTable() { - const intl = useIntl(); - const { invoices, selectedInvoice, isLoading } = useBillingInvoices(); const dataRows = useMemo( @@ -70,9 +67,7 @@ function BillingLineItemsTable() { <> - {intl.formatMessage({ - id: 'admin.billing.table.line_items.tooltip.download_pdf', - })} + Invoice {selectedInvoice.receipt_url ? ( ) : selectedInvoice.status === 'open' ? ( ) : selectedInvoice.status === 'paid' ? ( ) : ( )} ) : ( ) ) : null} diff --git a/src/components/tables/Billing/Rows.tsx b/src/components/tables/Billing/Rows.tsx index 285719f14f..5b0cfafe3d 100644 --- a/src/components/tables/Billing/Rows.tsx +++ b/src/components/tables/Billing/Rows.tsx @@ -33,7 +33,9 @@ function Row({ row, isSelected }: RowProps) { + `This billing period ended on ${formattedDate}` + } /> diff --git a/src/components/tables/Billing/index.tsx b/src/components/tables/Billing/index.tsx index 2eb8bbb60b..abdedf1ff8 100644 --- a/src/components/tables/Billing/index.tsx +++ b/src/components/tables/Billing/index.tsx @@ -4,8 +4,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Box, Table, TableContainer, TablePagination } from '@mui/material'; -import { useIntl } from 'react-intl'; - import Rows from 'src/components/tables/Billing/Rows'; import EntityTableBody from 'src/components/tables/EntityTable/TableBody'; import EntityTableHeader from 'src/components/tables/EntityTable/TableHeader'; @@ -34,8 +32,6 @@ export const columns: TableColumns[] = [ const ROWS_PER_PAGE = 4; function BillingHistoryTable() { - const intl = useIntl(); - const { allInvoices, selectedInvoice, @@ -75,14 +71,10 @@ function BillingHistoryTable() { }, [allInvoices, currentPage, selectedInvoice]); return ( - +
diff --git a/src/components/tables/cells/billing/TimeStamp.tsx b/src/components/tables/cells/billing/TimeStamp.tsx index bd2c52f21a..30ce59a7f6 100644 --- a/src/components/tables/cells/billing/TimeStamp.tsx +++ b/src/components/tables/cells/billing/TimeStamp.tsx @@ -1,6 +1,6 @@ import { Box, TableCell } from '@mui/material'; -import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; +import { format } from 'date-fns'; import CustomWidthTooltip from 'src/components/shared/CustomWidthTooltip'; import { stripTimeFromDate } from 'src/utils/billing-utils'; @@ -8,29 +8,18 @@ import { stripTimeFromDate } from 'src/utils/billing-utils'; interface Props { date: string; asLink?: boolean; - tooltipMessageId: string; + // Builds the tooltip text from the fully formatted date (e.g. + // "June 18, 2026"), letting the caller own the surrounding copy. + getTooltip: (formattedDate: string) => string; } -function TimeStamp({ date, asLink, tooltipMessageId }: Props) { - const intl = useIntl(); - +function TimeStamp({ date, asLink, getTooltip }: Props) { const strippedDate = stripTimeFromDate(date); return ( - } + title={getTooltip(format(strippedDate, 'MMMM d, yyyy'))} placement="bottom-start" > - - , + {format(strippedDate, 'MMM d')}, {' '} - + {format(strippedDate, 'yyyy')} diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index a5fd4df67a..b7cb9c86ba 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -32,74 +32,36 @@ export const AdminPage: Record = { 'admin.cli_api.refreshToken.revoke.message.named': `Remove the refresh token "{detail}"?`, 'admin.cli_api.refreshToken.revoke.permanent': `This action is permanent.`, - 'admin.billing.header': `Billing`, - 'admin.billing.message.freeTier': `The free tier lets you try ${CommonMessages.productName} with up to 2 tasks and 10GB per month without entering a credit card. Usage beyond these limits automatically starts a 30 day free trial.`, - 'admin.billing.message.paidTier': `Cloud tier`, - 'admin.billing.message.external': ` `, - 'admin.billing.message.external.gcp': `GCP Marketplace`, - 'admin.billing.message.external.aws': `AWS Marketplace`, - 'admin.billing.error.details.header': `There was a network issue.`, - 'admin.billing.error.details.message': `There was an error fetching your billing details. ${Errors['error.tryAgain']}`, - 'admin.billing.error.paymentMethodsError': `There was an error connecting with our payment provider. Please try again later.`, 'admin.billing.error.undefinedPricingTier': `An issue was encountered gathering information about the pricing tier associated with this tenant. Please {docLink}.`, 'admin.billing.error.undefinedPricingTier.docLink': `${CTAs['cta.support']}`, 'admin.billing.error.undefinedPricingTier.docPath': `${CommonMessages['support.email']}`, 'admin.billing.label.tiers': `Pricing Tier`, - 'admin.billing.label.lineItems': `Your bill for:`, - 'admin.billing.label.lineItems.empty': `No bill to display`, - 'admin.billing.label.lineItems.loading': `Loading your bill`, 'admin.billing.label.lineItems.tooltip': `A connector (AKA "task") is billed based on the pro-rated portion of a month during which it was enabled, as measured in "task-hours". Approximately 720 task-hours accrue for a connector that is running nonstop for an entire month.`, 'admin.billing.label.lineItems.tooltip.title': `Each active connector counts as a task`, 'admin.billing.tier.free': `Free`, 'admin.billing.tier.personal': `Cloud`, 'admin.billing.tier.enterprise': `Enterprise`, - 'admin.billing.graph.usageByMonth.header': `Usage by Month`, - 'admin.billing.graph.usageByMonth.dataFormatter': `{value} GB`, - 'admin.billing.graph.usageByMonth.hoursFormatter': `{value} hours`, 'admin.billing.graph.taskHoursByMonth.header': `Task Hours by Month`, - 'admin.billing.graph.taskHoursByMonth.formatValue': `{taskUsage} {taskUsage, plural, one {Hour} other {Hours}}`, 'admin.billing.graph.dataByTask.header': `Data Volume by Task`, 'admin.billing.graph.dataByTask.tooltip': `This graph displays the three, largest data processing tasks over the set interval.`, - 'admin.billing.table.history.header': `Invoices`, 'admin.billing.table.history.label.details': `Pricing Tier`, 'admin.billing.table.history.label.date_start': `Start Date`, 'admin.billing.table.history.label.date_end': `End Date`, 'admin.billing.table.history.label.usage': `Data / Tasks`, 'admin.billing.table.history.label.totalCost': `Total Cost`, - 'admin.billing.table.history.tooltip.date_start': `This billing period began on {timestamp}`, - 'admin.billing.table.history.tooltip.date_end': `This billing period ended on {timestamp}`, 'admin.billing.table.history.tooltip.dataVolume': `GB of data processed by tasks`, 'admin.billing.table.history.emptyTableDefault.header': `No information found.`, 'admin.billing.table.history.emptyTableDefault.message': `We couldn't find any billing information on file. Only administrators of a tenant are able to review billing information.`, - 'admin.billing.table.line_items.title': `Invoice Details`, 'admin.billing.table.line_items.label.description': `Description`, 'admin.billing.table.line_items.label.count': `Quantity`, 'admin.billing.table.line_items.label.rate': `Unit Price`, 'admin.billing.table.line_items.label.subtotal': `Subtotal`, - 'admin.billing.table.line_items.label.total': `Total Due`, 'admin.billing.table.line_items.emptyTableDefault.header': `No information found.`, 'admin.billing.table.line_items.emptyTableDefault.message': `We couldn't find any billing information on file. Only administrators of a tenant are able to review billing information.`, - 'admin.billing.table.line_items.tooltip.download_pdf': `Invoice`, - 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, - 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, - 'admin.billing.table.line_items.tooltip.view_receipt': `Receipt`, - 'admin.billing.table.line_items.tooltip.no_invoice': `No invoice available`, - 'admin.billing.paymentMethods.header': `Payment Information`, - 'admin.billing.paymentMethods.description': `Enter your payment information. You won't be charged until your account usage exceeds free tier limits.`, - 'admin.billing.paymentMethods.cta.addPaymentMethod': `Add Payment Method`, - 'admin.billing.paymentMethods.cta.addPaymentMethod.error': `There was an issue attempting to get a token from Stripe. You cannot currently add a payment method. ${Errors['error.tryAgain']}`, - 'admin.billing.paymentMethods.table.label.cardType': `Type`, - 'admin.billing.paymentMethods.table.label.name': `Name`, - 'admin.billing.paymentMethods.table.label.lastFour': `Last 4 Digits`, - 'admin.billing.paymentMethods.table.label.details': `Exp`, - 'admin.billing.paymentMethods.table.emptyTableDefault.message': `No payment methods available.`, - 'admin.billing.paymentMethods.delete.confirmation.title': `Delete payment method?`, - 'admin.billing.paymentMethods.delete.confirmation.message': `This payment method will be removed from your account. This action cannot be undone.`, - 'admin.billing.addPaymentMethods.title': `Add a payment method`, 'admin.billing.addPaymentMethods.stripeLoadError': `Unable to load the forms from Stripe. ${Errors['error.tryAgain']}`, 'admin.grants.confirmation.alert': `Access to all items will be removed and this action cannot be undone. Please review the list to continue.`,