diff --git a/src/api/billing.ts b/src/api/billing.ts index a5be30fc23..f3ed977842 100644 --- a/src/api/billing.ts +++ b/src/api/billing.ts @@ -2,74 +2,23 @@ import type { TenantPaymentDetails } from 'src/types'; import pLimit from 'p-limit'; -import { supabaseClient } from 'src/context/GlobalProviders'; -import { FUNCTIONS, invokeSupabase, TABLES } from 'src/services/supabase'; -import { formatDateForApi } from 'src/utils/billing-utils'; +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', - GET_TENANT_INVOICE: 'get-tenant-invoice', }; -export interface StripeInvoice { - id: string; - amount_due: number; - invoice_pdf: string; - hosted_invoice_url: string; - status: 'open' | 'paid' | 'void' | 'uncollectable'; -} - -export const getTenantInvoice = ( - tenant: string, - date_start: string, - date_end: string, - type: 'manual' | 'final' -) => { - return invokeSupabase<{ invoice?: StripeInvoice | null }>( - FUNCTIONS.BILLING, - { - operation: OPERATIONS.GET_TENANT_INVOICE, - tenant, - date_start, - date_end, - type, - } - ); -}; - -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; @@ -88,43 +37,14 @@ export interface Invoice { processed_data_gb: number; task_usage_hours: number; }; + // Stripe-sourced fields carried on the GQL invoice node. Absent on previews + // and on invoices that haven't been issued in Stripe yet. + status?: string | null; + invoice_pdf?: string | null; + hosted_invoice_url?: string | null; + receipt_url?: string | null; } -const invoicesQuery = [ - 'billed_prefix', - 'date_start', - 'date_end', - 'line_items', - 'subtotal', - 'invoice_type', - 'extra', -].join(', '); - -export const getInvoicesBetween = ( - billed_prefix: string, - date_start: Date, - date_end: Date -) => { - const formattedStart = formatDateForApi(date_start); - const formattedEnd = formatDateForApi(date_end); - - return supabaseClient - .from(TABLES.INVOICES_EXT) - .select(invoicesQuery) - .filter('billed_prefix', 'eq', billed_prefix) - .or( - `invoice_type.eq.manual,and(${[ - `date_start.gte.${formattedStart}`, - `date_start.lte.${formattedEnd}`, - `date_end.gte.${formattedStart}`, - `date_end.lte.${formattedEnd}`, - ].join(',')})` - ) - .order('date_start', { ascending: false }) - .throwOnError() - .returns(); -}; - export interface MultiplePaymentMethods { responses: any[]; errors: any[]; diff --git a/src/api/gql/billing.ts b/src/api/gql/billing.ts new file mode 100644 index 0000000000..3d70031b2b --- /dev/null +++ b/src/api/gql/billing.ts @@ -0,0 +1,109 @@ +import { graphql } from 'src/gql-types'; + +// Upper bound on invoices fetched per tenant. A tenant accrues ~12 invoices a +// year, so this comfortably covers the rolling six-month window the UI shows +// plus any older manual invoices, which the hook filters down client-side. +export const BILLING_INVOICE_FETCH_LIMIT = 100; + +// `lineItems` and `extra` are opaque JSON scalars in the schema, so codegen +// types them as `unknown`; the hook casts them to the InvoiceLineItem[] / extra +// shapes the rest of the billing UI already expects. `billed_prefix` is not on +// the node because the tenant is implied by the `tenant(name:)` parent. +export const TENANT_BILLING_INVOICES_QUERY = graphql(` + query TenantBillingInvoices($tenant: String!, $first: Int) { + tenant(name: $tenant) { + billing { + invoices(first: $first) { + nodes { + dateStart + dateEnd + invoiceType + subtotal + lineItems + extra + status + invoicePdf + hostedInvoiceUrl + paymentDetails { + status + receiptUrl + } + } + } + } + } + } +`); + +// 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/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/admin/Billing/AddPaymentMethod.tsx b/src/components/admin/Billing/AddPaymentMethod.tsx index 2786ff727a..dc14d5bd38 100644 --- a/src/components/admin/Billing/AddPaymentMethod.tsx +++ b/src/components/admin/Billing/AddPaymentMethod.tsx @@ -1,30 +1,30 @@ import type { Stripe } from '@stripe/stripe-js'; -import { Box, Button, Dialog, DialogTitle } 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'; import { PaymentForm } from 'src/components/admin/Billing/CapturePaymentMethod'; 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 { 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; } -function AddPaymentMethod({ +export function AddPaymentMethodDialog({ onSuccess, show, setupIntentSecret, @@ -32,80 +32,92 @@ function AddPaymentMethod({ stripePromise, tenant, }: 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 && setupIntentSecret !== INTENT_SECRET_ERROR; return ( - <> - - - - - setOpen(false)} - data-private - > - - {intl.formatMessage({ - id: 'admin.billing.addPaymentMethods.title', - })} - - {enable ? ( - - {!tenant ? null : ( - { - if (id) { - await setTenantPrimaryPaymentMethod( - tenant, - id - ); + {!tenant ? null : ( + { + if (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); + await onSuccess(id); + }} + onError={console.log} + /> + )} + + ) : null} + ); } - -export default AddPaymentMethod; diff --git a/src/components/admin/Billing/DeletePaymentMethodDialog.tsx b/src/components/admin/Billing/DeletePaymentMethodDialog.tsx new file mode 100644 index 0000000000..6009d48341 --- /dev/null +++ b/src/components/admin/Billing/DeletePaymentMethodDialog.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +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 [processing, setProcessing] = useState(false); + + const handleConfirm = async () => { + setProcessing(true); + + try { + await onConfirm(); + } finally { + setProcessing(false); + } + }; + + return ( + + Delete payment method? + + + + 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 630fb98d4c..c4c9496d59 100644 --- a/src/components/admin/Billing/LoadError.tsx +++ b/src/components/admin/Billing/LoadError.tsx @@ -1,31 +1,18 @@ -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; } 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/PaymentMethodRow.tsx b/src/components/admin/Billing/PaymentMethodRow.tsx index ebd7797cb5..16a6c92645 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 { 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,22 +14,21 @@ 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: { - address: { - city: string; - country: string; - line1: string; - line2: string; - postal_code: string; - state: string; - }; - email: string; name: string; }; card: { @@ -43,16 +42,13 @@ export interface PaymentMethodProps { | 'unionpay' | 'visa' | 'unknown'; - country: string; exp_month: number; exp_year: number; - last4: number; + last4: string; }; us_bank_account: { - account_holder_type: 'individual' | 'company'; - account_type: 'checking' | 'savings'; bank_name: string; - last4: number; + last4: string; }; } @@ -66,7 +62,13 @@ export const PaymentMethod = ({ primary, }: PaymentMethodProps) => { return ( - + {type === 'card' ? ( cardLogos[card.brand] ? ( @@ -78,33 +80,90 @@ 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' ? ( <> - 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..28aae0a36f 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,15 +17,10 @@ import { } from '@mui/material'; import { loadStripe } from '@stripe/stripe-js'; -import { FormattedMessage } from 'react-intl'; +import { Plus } from 'iconoir-react'; -import { - deleteTenantPaymentMethod, - getSetupIntentSecret, - getTenantPaymentMethods, - setTenantPrimaryPaymentMethod, -} from 'src/api/billing'; -import AddPaymentMethod from 'src/components/admin/Billing/AddPaymentMethod'; +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, @@ -32,37 +28,34 @@ 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'; 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', - width: 200, + 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', - }, - { - field: 'primary', - headerIntlKey: 'admin.billing.paymentMethods.table.label.primary', + header: 'Exp', }, { field: 'actions', - headerIntlKey: 'admin.billing.paymentMethods.table.label.actions', + align: 'center', + width: 100, }, ]; @@ -78,73 +71,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) { @@ -152,19 +100,25 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => { } }, [serverErrored]); + const enable = + setupIntentSecret !== INTENT_SECRET_LOADING && + setupIntentSecret !== INTENT_SECRET_ERROR; + return ( {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} @@ -175,41 +129,65 @@ 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." + } )} {serverErrored ? null : ( - setRefreshCounter((r) => r + 1)} - stripePromise={stripePromise} - setupIntentSecret={setupIntentSecret} - /> + <> + + { + if (id) { + await setPrimary(id); + } + // A card was added, so the list itself changed. + refresh(); + }} + stripePromise={stripePromise} + setupIntentSecret={setupIntentSecret} + /> + )} {serverErrored ? ( - + There was an error connecting with our payment provider. + Please try again later. ) : ( - +
{ > {columns.map((column, index) => ( - {column.headerIntlKey ? ( - - ) : null} + {column.header ?? null} ))} - {!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} /> )) ) : ( - + - + No payment methods available. @@ -274,6 +239,17 @@ const PaymentMethods = ({ showAddPayment }: AdminBillingProps) => {
)} + + setMethodIdToDelete(null)} + onConfirm={async () => { + if (methodIdToDelete) { + await deleteMethod(methodIdToDelete); + } + setMethodIdToDelete(null); + }} + />
); }; diff --git a/src/components/admin/Billing/PricingTierDetails.tsx b/src/components/admin/Billing/PricingTierDetails.tsx index 20db091910..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'; @@ -13,45 +11,38 @@ function PricingTierDetails() { const [externalPaymentMethod, marketPlaceProvider] = useTenantUsesExternalPayment(selectedTenant); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); const paymentMethodExists = useBillingStore( (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 (!billingStoreHydrated || typeof paymentMethodExists !== 'boolean') { + if (typeof paymentMethodExists !== 'boolean') { return ( - - - + {message} ); } else { - return ( - - - - ); + return {message}; } } 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..cc886df0ec 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -1,24 +1,14 @@ import type { AdminBillingProps } from 'src/components/admin/Billing/types'; -import { useEffect, useMemo } from 'react'; -import useConstant from 'use-constant'; +import { Box, Divider, Stack, Typography } from '@mui/material'; -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'; 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,178 +18,99 @@ 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; -// 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, headerLink: 'https://www.estuary.dev/pricing/', }); - 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 ( <> - - + +
- {intl.formatMessage({ id: 'admin.billing.header' })} + Billing - - - + + - - + + - + + + + + + - + - - - - - - - - - - - {intl.formatMessage({ - id: 'admin.billing.label.lineItems', - })} + Your bill for: ) : ( - intl.formatMessage({ - id: 'admin.billing.label.lineItems.empty', - }) + 'No bill to display' ) } > - {!active && hydrated ? ( + {!isLoading ? ( )} - - - - - - - - - - {intl.formatMessage({ - id: 'admin.billing.paymentMethods.header', - })} + + + + + + + Payment Information + + + + There was an error connecting with our + payment provider. Please try again later. - - - {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/graphs/TaskHoursByMonthGraph.tsx b/src/components/graphs/TaskHoursByMonthGraph.tsx index 581c0539c2..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 { @@ -29,18 +29,16 @@ import { } from 'src/components/graphs/tooltips'; import useTooltipConfig from 'src/components/graphs/useTooltipConfig'; import { defaultOutlineColor } 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 = 'task-hours-by-month'; function TaskHoursByMonthGraph() { const theme = useTheme(); - const intl = useIntl(); const tooltipConfig = useTooltipConfig(); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const resizeListener = useRef(null); const [myChart, setMyChart] = useState(null); @@ -53,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 })); @@ -75,17 +73,17 @@ 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 (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -132,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, @@ -153,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,8 +182,7 @@ function TaskHoursByMonthGraph() { } }, [ invoices, - billingStoreHydrated, - intl, + isLoading, months, myChart, seriesConfig, diff --git a/src/components/graphs/UsageByMonthGraph.tsx b/src/components/graphs/UsageByMonthGraph.tsx index 0de4754ab3..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,12 +23,11 @@ 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'; 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'; @@ -36,12 +36,10 @@ 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' }]); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const [myChart, setMyChart] = useState(null); @@ -53,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 })); @@ -78,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 }; }); @@ -86,17 +84,17 @@ 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 (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -126,8 +124,7 @@ function UsageByMonthGraph() { return undefined; }, [ invoices, - billingStoreHydrated, - intl, + isLoading, legendConfig, months, myChart, @@ -148,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', @@ -163,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', @@ -185,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`, }, }, { @@ -208,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, }, @@ -241,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 00693b82a2..b9dac295b9 100644 --- a/src/components/graphs/states/Wrapper.tsx +++ b/src/components/graphs/states/Wrapper.tsx @@ -2,42 +2,33 @@ 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'; -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 ( - } - message={ - - } + header="There was a network issue." + message="Please check your internet connection and reload the application." /> ); } - if (!billingStoreActive && billingStoreHydrated) { + if (!isLoading) { return hasLength(billingHistory) ? ( {children} ) : ( - - } - /> + ); } 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..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 5c1dcbce40..1eb5800993 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -1,32 +1,24 @@ -import type { StripeInvoice } from 'src/api/billing'; import type { TableColumns } from 'src/types'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Box, Button, - Skeleton, Table, TableContainer, tableRowClasses, } from '@mui/material'; import { CreditCard, Download } from 'iconoir-react'; -import { useIntl } from 'react-intl'; -import { getTenantInvoice } from 'src/api/billing'; import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared'; import Rows from 'src/components/tables/BillLineItems/Rows'; 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 { useTenantStore } from 'src/stores/Tenant'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { TableStatuses } from 'src/types'; export const columns: TableColumns[] = [ @@ -53,48 +45,29 @@ 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 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( () => , [selectedInvoice] ); - const [stripeInvoice, setStripeInvoice] = useState( - null + // 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) ); - useEffect(() => { - setStripeInvoice(null); - void (async () => { - if (selectedInvoice && selectedInvoice.invoice_type !== 'preview') { - const resp = await getTenantInvoice( - selectedTenant, - selectedInvoice.date_start, - selectedInvoice.date_end, - selectedInvoice.invoice_type - ); - if (resp.data?.invoice) { - setStripeInvoice(resp.data.invoice); - } - } - })(); - }, [selectedInvoice, selectedTenant]); - return ( <>
@@ -129,38 +102,56 @@ function BillingLineItemsTable() { sx={{ display: 'flex', justifyContent: 'space-between', - marginTop: 2, flexGrow: 1, alignItems: 'end', }} > - {selectedInvoice?.invoice_type !== 'preview' ? ( - hydrated ? ( + {selectedInvoice && + selectedInvoice.invoice_type !== 'preview' ? ( + hasInvoiceArtifact ? ( - {stripeInvoice?.status === 'open' ? ( + {selectedInvoice.receipt_url ? ( + + ) : selectedInvoice.status === 'open' ? ( - ) : stripeInvoice?.status === 'paid' ? ( + ) : selectedInvoice.status === 'paid' ? ( ) : ( )} ) : ( - <> - - - + ) ) : null} diff --git a/src/components/tables/Billing/Rows.tsx b/src/components/tables/Billing/Rows.tsx index 8d0c2f17f8..5b0cfafe3d 100644 --- a/src/components/tables/Billing/Rows.tsx +++ b/src/components/tables/Billing/Rows.tsx @@ -1,11 +1,8 @@ import type { Invoice } from 'src/api/billing'; import type { InvoiceId } from 'src/utils/billing-utils'; -import { TableCell, TableRow, Typography } from '@mui/material'; +import { Box, 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,25 +30,24 @@ function Row({ row, isSelected }: RowProps) { onClick={() => setSelectedInvoice(invoiceId(row))} sx={{ cursor: 'pointer' }} > - + `This billing period ended on ${formattedDate}` + } /> - - - + {/* 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 + @@ -60,11 +56,10 @@ function Row({ row, isSelected }: RowProps) { ); } -// TODO (billing): Remove pagination placeholder when the new RPC is available. function Rows({ data, selectedInvoice }: RowsProps) { return ( <> - {data.slice(0, 4).map((record, index) => ( + {data.map((record, index) => ( state.active); - const hydrated = useBillingStore((state) => state.hydrated); - const networkFailed = useBillingStore((state) => state.networkFailed); - const billingHistory = useBillingStore((state) => state.invoices); - - const dataRows = useMemo( - () => - billingHistory.length > 0 ? ( - - ) : null, - [billingHistory, selectedInvoice] - ); + const { + allInvoices, + selectedInvoice, + isLoading, + networkFailed, + selectedTenant, + } = useBillingInvoices(); + + const [page, setPage] = useState(0); + + // 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); + }, [selectedTenant]); + + const pageCount = Math.ceil(allInvoices.length / ROWS_PER_PAGE); + const currentPage = Math.min(page, Math.max(0, pageCount - 1)); + + const dataRows = useMemo(() => { + if (allInvoices.length === 0) { + return null; + } + + const start = currentPage * ROWS_PER_PAGE; + + return ( + + ); + }, [allInvoices, currentPage, selectedInvoice]); return ( - - - - - 0 - ? { status: TableStatuses.DATA_FETCHED } - : networkFailed - ? { status: TableStatuses.NETWORK_FAILED } - : { status: TableStatuses.NO_EXISTING_DATA } - } - loading={Boolean(active || !hydrated)} - rows={dataRows} + + +
+ + + 0 + ? { status: TableStatuses.DATA_FETCHED } + : networkFailed + ? { status: TableStatuses.NETWORK_FAILED } + : { status: TableStatuses.NO_EXISTING_DATA } + } + loading={isLoading} + rows={dataRows} + /> +
+
+ + {allInvoices.length > ROWS_PER_PAGE ? ( + setPage(newPage)} + ActionsComponent={TablePaginationActions} + sx={{ mt: 'auto' }} /> - -
+ ) : null} +
); } 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/billing/TimeStamp.tsx b/src/components/tables/cells/billing/TimeStamp.tsx index 400baf1d97..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" > - + {/* Keep the month and day together so the year is the only + wrap point and drops to the next line in a narrow column. */} + + {format(strippedDate, 'MMM d')}, + {' '} + {format(strippedDate, 'yyyy')} 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/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/context/URQL.tsx b/src/context/URQL.tsx index e2a67e7cdb..c2a6bc7397 100644 --- a/src/context/URQL.tsx +++ b/src/context/URQL.tsx @@ -60,8 +60,12 @@ function UrqlConfigProvider({ children }: BaseComponentProps) { InviteLink: (data) => null, LiveSpecRef: (_data) => null, PrefixRef: (_data) => null, + RefreshTokenInfo: (_data) => null, StorageMapping: (data) => null, DataPlane: (data) => null, + Tenant: (_data) => null, + TenantBilling: (_data) => null, + Invoice: (_data) => null, }, updates: { Mutation: { @@ -80,6 +84,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..b9680d8120 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -19,6 +19,11 @@ 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 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, @@ -27,6 +32,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, @@ -44,6 +52,11 @@ 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 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, @@ -52,6 +65,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, @@ -98,6 +114,26 @@ export function graphql(source: "\n mutation UpdateAlertSubscriptionMutation( * 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 AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n"): (typeof documents)["\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\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 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. */ @@ -130,6 +166,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 b0db5e452b..4306dec253 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'; @@ -956,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. * @@ -980,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. @@ -1012,6 +1095,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. * @@ -1048,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; @@ -1077,6 +1180,11 @@ export type MutationRootRedeemInviteLinkArgs = { }; +export type MutationRootRevokeRefreshTokenArgs = { + id: Scalars['Id']['input']; +}; + + export type MutationRootSetBillingPaymentMethodArgs = { paymentMethodId: Scalars['String']['input']; tenant: Scalars['String']['input']; @@ -1104,6 +1212,12 @@ export type MutationRootUpdateAlertSubscriptionArgs = { }; +export type MutationRootUpdateDataPlanePrivateLinksArgs = { + dataPlaneName: Scalars['String']['input']; + privateLinks: Array; +}; + + export type MutationRootUpdateStorageMappingArgs = { catalogPrefix: Scalars['Prefix']['input']; detail?: InputMaybe; @@ -1195,6 +1309,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. */ @@ -1322,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. * @@ -1405,6 +1537,12 @@ export type QueryRootPrefixesArgs = { }; +export type QueryRootRefreshTokensArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + + export type QueryRootStorageMappingsArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1427,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'; /** @@ -1786,6 +1962,44 @@ export type AlertTypeQueryVariables = Exact<{ [key: string]: never; }>; export type AlertTypeQuery = { __typename?: 'QueryRoot', alertTypes: Array<{ __typename?: 'AlertTypeInfo', alertType: AlertType, description: string, displayName: string, isDefault: boolean, isSystem: boolean }> }; +export type TenantBillingInvoicesQueryVariables = Exact<{ + tenant: Scalars['String']['input']; + first?: InputMaybe; +}>; + + +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; @@ -1849,6 +2063,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']; @@ -1928,6 +2166,11 @@ 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"}},{"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; @@ -1936,6 +2179,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 6c46c564ff..66fbf3c76e 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""" @@ -979,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. @@ -1008,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! """ @@ -1044,6 +1138,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 +1268,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.""" @@ -1320,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. @@ -1339,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/hooks/billing/useBillingInvoices.ts b/src/hooks/billing/useBillingInvoices.ts new file mode 100644 index 0000000000..47d338154b --- /dev/null +++ b/src/hooks/billing/useBillingInvoices.ts @@ -0,0 +1,161 @@ +import type { Invoice, InvoiceLineItem } from 'src/api/billing'; + +import { useMemo } from 'react'; +import useConstant from 'use-constant'; + +import { + compareDesc, + endOfMonth, + isWithinInterval, + startOfMonth, + subMonths, +} from 'date-fns'; +import { useQuery } from 'urql'; + +import { + BILLING_INVOICE_FETCH_LIMIT, + TENANT_BILLING_INVOICES_QUERY, +} from 'src/api/gql/billing'; +import { useBillingStore } from 'src/stores/Billing'; +import { useTenantStore } from 'src/stores/Tenant'; +import { invoiceId, stripTimeFromDate } from 'src/utils/billing-utils'; + +export interface UseBillingInvoicesResult { + // The rolling-window subset (plus manual invoices) used by the usage + // graphs, which only chart the recent period. + invoices: Invoice[]; + // Every invoice for the tenant, newest first. Drives the full, paginated + // history table. + allInvoices: Invoice[]; + // The invoice currently shown in the line-item/detail views: the stored + // selection if it still exists in this tenant's data, otherwise the newest + // invoice. Falling back this way means an org switch self-corrects without + // anyone resetting state. + selectedInvoice: Invoice | null; + 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 { + start: Date; + end: Date; +} + +// The GQL invoice node is camelCased and omits `billed_prefix` (the tenant is +// the query parent). Map it back to the shape the billing UI already consumes. +const mapInvoice = ( + node: { + dateStart: string; + dateEnd: string; + invoiceType: string; + subtotal: number; + lineItems: unknown; + extra: unknown; + status?: string | null; + invoicePdf?: string | null; + hostedInvoiceUrl?: string | null; + paymentDetails?: { receiptUrl?: string | null } | null; + }, + tenant: string +): Invoice => ({ + billed_prefix: tenant, + date_start: node.dateStart, + date_end: node.dateEnd, + invoice_type: node.invoiceType.toLowerCase() as Invoice['invoice_type'], + 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: +// invoices whose start and end both fall inside the rolling window, plus any +// manual invoice regardless of date. +const isVisible = (invoice: Invoice, { start, end }: DateWindow): boolean => { + if (invoice.invoice_type === 'manual') { + return true; + } + + return ( + isWithinInterval(stripTimeFromDate(invoice.date_start), { + start, + end, + }) && + isWithinInterval(stripTimeFromDate(invoice.date_end), { start, end }) + ); +}; + +// Fetches the selected tenant's invoices via GraphQL. urql keys its cache on +// the query variables, so switching tenants re-runs the query and a previously +// viewed tenant is served from cache — the previous tenant's data is never +// shown. Server-side filtering on `billing.invoices` is narrower than the old +// PostgREST query (no "window OR manual" predicate), so the window/manual +// filter and newest-first sort are reproduced here. +export function useBillingInvoices(): UseBillingInvoicesResult { + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const selectedInvoiceId = useBillingStore( + (state) => state.selectedInvoiceId + ); + + const dateWindow = useConstant(() => { + const end = endOfMonth(new Date()); + + return { start: startOfMonth(subMonths(end, 5)), end }; + }); + + const [{ data, fetching, error }] = useQuery({ + query: TENANT_BILLING_INVOICES_QUERY, + variables: { + tenant: selectedTenant, + first: BILLING_INVOICE_FETCH_LIMIT, + }, + pause: !selectedTenant, + }); + + const allInvoices = useMemo(() => { + const nodes = data?.tenant?.billing.invoices.nodes ?? []; + + return nodes + .map((node) => mapInvoice(node, selectedTenant)) + .sort((a, b) => + compareDesc( + stripTimeFromDate(a.date_start), + stripTimeFromDate(b.date_start) + ) + ); + }, [data, selectedTenant]); + + const invoices = useMemo( + () => allInvoices.filter((invoice) => isVisible(invoice, dateWindow)), + [allInvoices, dateWindow] + ); + + const selectedInvoice = useMemo(() => { + if (allInvoices.length === 0) { + return null; + } + + return ( + allInvoices.find( + (invoice) => invoiceId(invoice) === selectedInvoiceId + ) ?? allInvoices[0] + ); + }, [allInvoices, selectedInvoiceId]); + + return { + invoices, + allInvoices, + selectedInvoice, + isLoading: fetching, + networkFailed: Boolean(error?.networkError), + errorExists: Boolean(error), + selectedTenant, + }; +} diff --git a/src/hooks/billing/useBillingPaymentMethods.ts b/src/hooks/billing/useBillingPaymentMethods.ts new file mode 100644 index 0000000000..62f27f3454 --- /dev/null +++ b/src/hooks/billing/useBillingPaymentMethods.ts @@ -0,0 +1,220 @@ +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. +const mapPaymentMethod = (node: PaymentMethodNode): BillingPaymentMethod => ({ + id: node.id, + type: node.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 ?? '', + }, + us_bank_account: { + bank_name: node.usBankAccount?.bankName ?? '', + last4: node.usBankAccount?.last4 ?? '', + }, +}); + +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 624ac51642..b7cb9c86ba 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -12,85 +12,56 @@ 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.`, - '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': `Recent History`, - 'admin.billing.table.history.label.dataVolume': `Data Volume`, '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.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': `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.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': `Details`, - '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.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.`, 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/Billing.ts b/src/stores/Billing.ts index 8f45e333c2..56e161313f 100644 --- a/src/stores/Billing.ts +++ b/src/stores/Billing.ts @@ -1,5 +1,3 @@ -import type { Invoice } from 'src/api/billing'; -import type { StoreWithHydration } from 'src/stores/extensions/Hydration'; import type { InvoiceId } from 'src/utils/billing-utils'; import type { NamedSet } from 'zustand/middleware'; @@ -9,42 +7,21 @@ import { devtools } from 'zustand/middleware'; import produce from 'immer'; import { isArray } from 'lodash'; -import { - getInitialHydrationData, - getStoreWithHydrationSettings, -} from 'src/stores/extensions/Hydration'; -import { invoiceId } from 'src/utils/billing-utils'; import { hasLength } from 'src/utils/misc-utils'; import { devtoolsOptions } from 'src/utils/store-utils'; -interface BillingState extends StoreWithHydration { +interface BillingState { selectedInvoiceId: InvoiceId | null; setSelectedInvoice: (value: InvoiceId) => void; - invoices: Invoice[]; - setInvoices: (value: Invoice[]) => void; - paymentMethodExists: boolean | null; setPaymentMethodExists: (value: any[] | undefined) => void; - - resetState: () => void; } -const getInitialStateData = (): Pick< - BillingState, - 'invoices' | 'paymentMethodExists' | 'selectedInvoiceId' -> => { +const getInitialState = (set: NamedSet): BillingState => { return { selectedInvoiceId: null, - invoices: [], paymentMethodExists: null, - }; -}; - -const getInitialState = (set: NamedSet): BillingState => { - return { - ...getInitialStateData(), - ...getStoreWithHydrationSettings('Billing', set), setSelectedInvoice: (value) => { set( @@ -56,20 +33,6 @@ const getInitialState = (set: NamedSet): BillingState => { ); }, - setInvoices: (value) => { - set( - produce((state: BillingState) => { - if (state.active) { - state.invoices = value; - state.selectedInvoiceId = - value.length > 0 ? invoiceId(value[0]) : null; - } - }), - false, - 'Billing Details Set' - ); - }, - setPaymentMethodExists: (value) => { set( produce((state: BillingState) => { @@ -80,28 +43,9 @@ const getInitialState = (set: NamedSet): BillingState => { 'Payment Exists Updated' ); }, - - resetState: () => { - set( - { ...getInitialStateData(), ...getInitialHydrationData() }, - false, - 'State Reset' - ); - }, }; }; export const useBillingStore = create()( devtools((set) => getInitialState(set), devtoolsOptions('billing')) ); - -// Selector Hooks -export const useBilling_selectedInvoice = () => { - return useBillingStore((state) => - state.selectedInvoiceId - ? (state.invoices.find( - (inv) => invoiceId(inv) === state.selectedInvoiceId - ) ?? null) - : null - ); -}; 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;