From cb4c65c559827fdc03139973c25432a9bae97171 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 18 Jun 2026 22:02:34 -0400 Subject: [PATCH 1/9] service accounts --- src/api/gql/serviceAccounts.ts | 147 ++++++++++++ src/app/routes.ts | 15 ++ src/components/admin/Api/AccessToken.tsx | 10 +- .../Api/RefreshToken/Dialog/Description.tsx | 38 +++ .../admin/Api/RefreshToken/Dialog/Title.tsx | 54 +++++ .../admin/Api/RefreshToken/index.tsx | 4 + src/components/admin/Api/index.tsx | 35 ++- .../admin/ServiceAccounts/Actions.tsx | 143 +++++++++++ .../admin/ServiceAccounts/ApiKeysRow.tsx | 222 ++++++++++++++++++ .../ServiceAccounts/CreateApiKeyDialog.tsx | 185 +++++++++++++++ .../admin/ServiceAccounts/CreateDialog.tsx | 213 +++++++++++++++++ src/components/admin/ServiceAccounts/Row.tsx | 127 ++++++++++ .../admin/ServiceAccounts/Table.tsx | 150 ++++++++++++ .../admin/ServiceAccounts/index.tsx | 42 ++++ src/components/admin/Tabs.tsx | 5 + src/context/Router/index.tsx | 17 ++ src/context/URQL.tsx | 16 ++ src/gql-types/gql.ts | 15 ++ src/gql-types/graphql.ts | 195 +++++++++++++++ src/gql-types/schema.graphql | 152 ++++++++++++ src/lang/en-US/AdminPage.ts | 15 ++ src/lang/en-US/RouteTitles.ts | 2 + src/pages/FlowctlAccessToken.tsx | 81 +++++++ 23 files changed, 1856 insertions(+), 27 deletions(-) create mode 100644 src/api/gql/serviceAccounts.ts create mode 100644 src/components/admin/Api/RefreshToken/Dialog/Description.tsx create mode 100644 src/components/admin/Api/RefreshToken/Dialog/Title.tsx create mode 100644 src/components/admin/ServiceAccounts/Actions.tsx create mode 100644 src/components/admin/ServiceAccounts/ApiKeysRow.tsx create mode 100644 src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx create mode 100644 src/components/admin/ServiceAccounts/CreateDialog.tsx create mode 100644 src/components/admin/ServiceAccounts/Row.tsx create mode 100644 src/components/admin/ServiceAccounts/Table.tsx create mode 100644 src/components/admin/ServiceAccounts/index.tsx create mode 100644 src/pages/FlowctlAccessToken.tsx diff --git a/src/api/gql/serviceAccounts.ts b/src/api/gql/serviceAccounts.ts new file mode 100644 index 0000000000..362a373d2f --- /dev/null +++ b/src/api/gql/serviceAccounts.ts @@ -0,0 +1,147 @@ +import type { ServiceAccount } from 'src/gql-types/graphql'; + +import { useMemo } from 'react'; + +import { useMutation, useQuery } from 'urql'; + +import { graphql } from 'src/gql-types'; + +const DEFAULT_SERVICE_ACCOUNTS: ServiceAccount[] = []; + +export const SERVICE_ACCOUNTS_PAGE_SIZE = 10; + +const SERVICE_ACCOUNTS_QUERY = graphql(` + query ServiceAccounts($first: Int, $after: String) { + serviceAccounts(first: $first, after: $after) { + edges { + node { + id + displayName + prefix + capability + createdBy + createdAt + updatedAt + disabledAt + lastUsedAt + apiKeys { + id + label + createdAt + createdBy + expiresAt + lastUsedAt + } + } + cursor + } + pageInfo { + ...PageInfoFields + } + } + } +`); + +export function useServiceAccounts(afterCursor?: string) { + const [{ fetching, data, error }, reexecute] = useQuery({ + query: SERVICE_ACCOUNTS_QUERY, + variables: { + first: SERVICE_ACCOUNTS_PAGE_SIZE, + after: afterCursor, + }, + }); + + const serviceAccounts = useMemo( + () => + data?.serviceAccounts?.edges?.map((edge) => edge.node) ?? + DEFAULT_SERVICE_ACCOUNTS, + [data] + ); + + const pageInfo = data?.serviceAccounts?.pageInfo ?? null; + + return { + serviceAccounts, + fetching, + error, + pageInfo, + pageSize: SERVICE_ACCOUNTS_PAGE_SIZE, + reexecute, + }; +} + +export const CREATE_SERVICE_ACCOUNT = graphql(` + mutation CreateServiceAccount( + $prefix: Prefix! + $capability: Capability! + $displayName: String! + ) { + createServiceAccount( + prefix: $prefix + capability: $capability + displayName: $displayName + ) { + id + displayName + prefix + capability + createdAt + createdBy + } + } +`); + +export const DISABLE_SERVICE_ACCOUNT = graphql(` + mutation DisableServiceAccount($id: UUID!) { + disableServiceAccount(id: $id) + } +`); + +export const ENABLE_SERVICE_ACCOUNT = graphql(` + mutation EnableServiceAccount($id: UUID!) { + enableServiceAccount(id: $id) + } +`); + +export const CREATE_API_KEY = graphql(` + mutation CreateApiKey( + $serviceAccountId: UUID! + $label: String! + $validFor: String! + ) { + createApiKey( + serviceAccountId: $serviceAccountId + label: $label + validFor: $validFor + ) { + id + secret + } + } +`); + +export const REVOKE_API_KEY = graphql(` + mutation RevokeApiKey($id: Id!) { + revokeApiKey(id: $id) + } +`); + +export function useCreateServiceAccount() { + return useMutation(CREATE_SERVICE_ACCOUNT); +} + +export function useDisableServiceAccount() { + return useMutation(DISABLE_SERVICE_ACCOUNT); +} + +export function useEnableServiceAccount() { + return useMutation(ENABLE_SERVICE_ACCOUNT); +} + +export function useCreateApiKey() { + return useMutation(CREATE_API_KEY); +} + +export function useRevokeApiKey() { + return useMutation(REVOKE_API_KEY); +} diff --git a/src/app/routes.ts b/src/app/routes.ts index 930d42f76e..29fb2212bd 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -31,6 +31,11 @@ const admin = { fullPath: '/admin/billing/paymentMethod/new', }, }, + serviceAccounts: { + title: 'routeTitle.admin.serviceAccounts', + path: 'serviceAccounts', + fullPath: '/admin/serviceAccounts', + }, settings: { title: 'routeTitle.admin.settings', path: 'settings', @@ -154,6 +159,15 @@ const express = { }, }; +const flowctl = { + accessToken: { + title: 'routeTitle.flowctl.accessToken', + path: 'accessToken', + fullPath: '/flowctl/accessToken', + }, + path: 'flowctl', +}; + const home = { title: 'routeTitle.home', path: '/welcome', @@ -264,6 +278,7 @@ export const authenticatedRoutes = { collections, dataPlaneAuth, express, + flowctl, home, materializations, marketplace: marketplace.authenticated, diff --git a/src/components/admin/Api/AccessToken.tsx b/src/components/admin/Api/AccessToken.tsx index 682bd917d6..2415367f68 100644 --- a/src/components/admin/Api/AccessToken.tsx +++ b/src/components/admin/Api/AccessToken.tsx @@ -1,7 +1,5 @@ import { Box, Stack, Typography } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; - import SingleLineCode from 'src/components/content/SingleLineCode'; import ExternalLink from 'src/components/shared/ExternalLink'; import { useUserStore } from 'src/context/User/useUserContextStore'; @@ -10,7 +8,7 @@ function AccessToken() { const session = useUserStore((state) => state.session); return ( - + - + Access Token - + Docs - + Access tokens are used to login to flowctl for the first time. {/* TODO (defect): Display an error in the event the access token does not exist. */} diff --git a/src/components/admin/Api/RefreshToken/Dialog/Description.tsx b/src/components/admin/Api/RefreshToken/Dialog/Description.tsx new file mode 100644 index 0000000000..04534c63ff --- /dev/null +++ b/src/components/admin/Api/RefreshToken/Dialog/Description.tsx @@ -0,0 +1,38 @@ +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/Title.tsx b/src/components/admin/Api/RefreshToken/Dialog/Title.tsx new file mode 100644 index 0000000000..291ca11c0e --- /dev/null +++ b/src/components/admin/Api/RefreshToken/Dialog/Title.tsx @@ -0,0 +1,54 @@ +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/index.tsx b/src/components/admin/Api/RefreshToken/index.tsx index f45311ed73..00942254cb 100644 --- a/src/components/admin/Api/RefreshToken/index.tsx +++ b/src/components/admin/Api/RefreshToken/index.tsx @@ -15,7 +15,11 @@ export function RefreshToken() { fontWeight: '400', }} > +<<<<<<< HEAD +======= + +>>>>>>> 72f13fa7 (service accoutns) diff --git a/src/components/admin/Api/index.tsx b/src/components/admin/Api/index.tsx index 9e9e93fe6c..0daafc42ea 100644 --- a/src/components/admin/Api/index.tsx +++ b/src/components/admin/Api/index.tsx @@ -1,10 +1,8 @@ -import { Divider, Grid, Typography } from '@mui/material'; +import { Box, Link, Typography } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; +import { Link as RouterLink } from 'react-router-dom'; import { authenticatedRoutes } from 'src/app/routes'; -import AccessToken from 'src/components/admin/Api/AccessToken'; -import { RefreshToken } from 'src/components/admin/Api/RefreshToken'; import AdminTabs from 'src/components/admin/Tabs'; import usePageTitle from 'src/hooks/usePageTitle'; @@ -17,23 +15,18 @@ function AdminApi() { <> - - - - - - - - - - - - - - - - - + + + Refresh tokens and access tokens have moved to the{' '} + + Service Accounts + {' '} + tab. + + ); } diff --git a/src/components/admin/ServiceAccounts/Actions.tsx b/src/components/admin/ServiceAccounts/Actions.tsx new file mode 100644 index 0000000000..e8b4914b1a --- /dev/null +++ b/src/components/admin/ServiceAccounts/Actions.tsx @@ -0,0 +1,143 @@ +import type { ServiceAccount } from 'src/gql-types/graphql'; + +import { useRef, useState } from 'react'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; + +import { + useDisableServiceAccount, + useEnableServiceAccount, +} from 'src/api/gql/serviceAccounts'; +import Error from 'src/components/shared/Error'; + +interface Props { + serviceAccount: Pick; +} + +function ServiceAccountActions({ serviceAccount }: Props) { + const isDisabled = Boolean(serviceAccount.disabledAt); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [error, setError] = useState(null); + const disabledAtOpen = useRef(isDisabled); + + const [{ fetching: disabling }, disableServiceAccount] = + useDisableServiceAccount(); + const [{ fetching: enabling }, enableServiceAccount] = + useEnableServiceAccount(); + + const busy = disabling || enabling; + + const handleToggle = async () => { + setError(null); + + if (disabledAtOpen.current) { + const result = await enableServiceAccount({ + id: serviceAccount.id, + }); + + if (result.error) { + setError(result.error.message); + return; + } + + setConfirmOpen(false); + } else { + const result = await disableServiceAccount({ + id: serviceAccount.id, + }); + + if (result.error) { + setError(result.error.message); + return; + } + + setConfirmOpen(false); + } + }; + + return ( + <> + + + setConfirmOpen(false)} + maxWidth="xs" + fullWidth + slotProps={{ + transition: { onExited: () => setError(null) }, + }} + > + + {disabledAtOpen.current + ? 'Restore Service Account' + : 'Disable Service Account'} + + + + + {error ? ( + + ) : null} + + {disabledAtOpen.current + ? `Restore "${serviceAccount.displayName}"? This will not restore previously revoked API keys — you must create new ones.` + : `Disable "${serviceAccount.displayName}"?`} + + + {!disabledAtOpen.current ? ( + + All active API keys will be permanently revoked. + You will need to create new keys if you restore + this account. + + ) : null} + + + + + + + + + + ); +} + +export default ServiceAccountActions; diff --git a/src/components/admin/ServiceAccounts/ApiKeysRow.tsx b/src/components/admin/ServiceAccounts/ApiKeysRow.tsx new file mode 100644 index 0000000000..5301f9d841 --- /dev/null +++ b/src/components/admin/ServiceAccounts/ApiKeysRow.tsx @@ -0,0 +1,222 @@ +import type { ApiKeyInfo, ServiceAccount } from 'src/gql-types/graphql'; + +import { useState } from 'react'; + +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { DateTime } from 'luxon'; + +import { useRevokeApiKey } from 'src/api/gql/serviceAccounts'; +import CreateApiKeyDialog from 'src/components/admin/ServiceAccounts/CreateApiKeyDialog'; +import Error from 'src/components/shared/Error'; + +interface Props { + serviceAccount: Pick; + isDisabled: boolean; +} + +function isExpired(expiresAt: string): boolean { + return DateTime.fromISO(expiresAt) < DateTime.now(); +} + +function ApiKeyRow({ apiKey }: { apiKey: ApiKeyInfo }) { + const [, revokeApiKey] = useRevokeApiKey(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [revoking, setRevoking] = useState(false); + const [error, setError] = useState(null); + + const expired = isExpired(apiKey.expiresAt); + + const handleRevoke = async () => { + setError(null); + setRevoking(true); + + const result = await revokeApiKey({ id: apiKey.id }); + + setRevoking(false); + + if (result.error) { + setError(result.error.message); + return; + } + + setConfirmOpen(false); + }; + + return ( + + + {apiKey.label} + + + + + {DateTime.fromISO(apiKey.createdAt).toLocaleString( + DateTime.DATE_MED + )} + + + + + + + {DateTime.fromISO(apiKey.expiresAt).toLocaleString( + DateTime.DATE_MED + )} + + {expired ? ( + + ) : null} + + + + + + {apiKey.lastUsedAt + ? DateTime.fromISO(apiKey.lastUsedAt).toRelative() + : 'Never'} + + + + + + + setConfirmOpen(false)} + maxWidth="xs" + fullWidth + > + Revoke API Key + + + {error ? ( + + ) : null} + + {`Revoke "${apiKey.label}"? This action is permanent.`} + + {apiKey.lastUsedAt && + DateTime.fromISO(apiKey.lastUsedAt) > + DateTime.now().minus({ hours: 1 }) ? ( + + {`Processes that authenticated with this key may still have access for up to ${Math.ceil(DateTime.fromISO(apiKey.lastUsedAt).plus({ hours: 1 }).diff(DateTime.now(), 'minutes').minutes)} minutes.`} + + ) : null} + + + + + + + + + + ); +} + +function ApiKeysRow({ serviceAccount, isDisabled }: Props) { + return ( + + + + + API Keys + + {!isDisabled ? ( + + ) : null} + + + {serviceAccount.apiKeys.length === 0 ? ( + + No API keys. Create one to enable programmatic + authentication. + + ) : ( + + + + Label + Created + Expires + Last Used + + + + + {serviceAccount.apiKeys.map((key) => ( + + ))} + +
+ )} +
+
+
+ ); +} + +export default ApiKeysRow; diff --git a/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx new file mode 100644 index 0000000000..ff8d093abf --- /dev/null +++ b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { useCreateApiKey } from 'src/api/gql/serviceAccounts'; +import SingleLineCode from 'src/components/content/SingleLineCode'; +import AlertBox from 'src/components/shared/AlertBox'; +import { hasLength } from 'src/utils/misc-utils'; + +const VALIDITY_OPTIONS = [ + { label: '90 days', value: 'P90D' }, + { label: '180 days', value: 'P180D' }, + { label: '1 year', value: 'P1Y' }, +]; + +interface Props { + serviceAccountId: string; + serviceAccountName: string; +} + +function CreateApiKeyDialog({ serviceAccountId, serviceAccountName }: Props) { + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(''); + const [validFor, setValidFor] = useState('P90D'); + const [secret, setSecret] = useState(null); + const [error, setError] = useState(null); + + const [{ fetching }, createApiKey] = useCreateApiKey(); + + const resetForm = () => { + setLabel(''); + setValidFor('P90D'); + setSecret(null); + setError(null); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleCreate = async () => { + setError(null); + + if (!hasLength(label)) { + return; + } + + const result = await createApiKey({ + serviceAccountId, + label, + validFor, + }); + + if (result.error || !result.data?.createApiKey) { + setError( + result.error?.message ?? + 'There was an error creating the API key.' + ); + return; + } + + setSecret(result.data.createApiKey.secret); + }; + + return ( + <> + + + + + {`Create API Key for ${serviceAccountName}`} + + + + + {error ? ( + + {error} + + ) : null} + + {secret ? ( + + + Copy this API key now — it will not be shown + again. Use it as the value of FLOW_API_KEY + in your CI/CD environment. + + + + + ) : ( + <> + setLabel(e.target.value)} + required + size="small" + fullWidth + placeholder="e.g. GitHub Actions" + /> + + + Lifetime + + + + )} + + + + + {secret ? ( + + ) : ( + <> + + + + )} + + + + ); +} + +export default CreateApiKeyDialog; diff --git a/src/components/admin/ServiceAccounts/CreateDialog.tsx b/src/components/admin/ServiceAccounts/CreateDialog.tsx new file mode 100644 index 0000000000..447c8aaa55 --- /dev/null +++ b/src/components/admin/ServiceAccounts/CreateDialog.tsx @@ -0,0 +1,213 @@ +import { useMemo, useState } from 'react'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { useIntl } from 'react-intl'; + +import { useLiveSpecs } from 'src/api/gql/liveSpecs'; +import { useCreateServiceAccount } from 'src/api/gql/serviceAccounts'; +import { useStorageMappings } from 'src/api/gql/storageMappings'; +import AlertBox from 'src/components/shared/AlertBox'; +import { useCouldMatchRoot } from 'src/components/shared/LeavesAutocomplete'; +import { LeavesAutocomplete } from 'src/components/shared/LeavesAutocomplete/LeavesAutocomplete'; +import { useTenantStore } from 'src/stores/Tenant/Store'; +import { hasLength } from 'src/utils/misc-utils'; + +// 'none' and 'write' intentionally omitted +type Capability = 'admin' | 'read'; + +interface CreateServiceAccountDialogProps { + open: boolean; + onClose: () => void; +} + +export function CreateServiceAccountDialog({ + open, + onClose, +}: CreateServiceAccountDialogProps) { + const intl = useIntl(); + + const [displayName, setDisplayName] = useState(''); + const [prefix, setPrefix] = useState(''); + const [capability, setCapability] = useState('admin'); + const [error, setError] = useState(null); + // final (startsWith) validation only applies after blur or submit; + // typing switches back to partial-only validation + const [finalEnabled, setFinalEnabled] = useState(false); + + const [{ fetching }, createServiceAccount] = useCreateServiceAccount(); + + const { storageMappings } = useStorageMappings(); + const liveSpecNames = useLiveSpecs(); + const selectedTenant = useTenantStore((state) => state.selectedTenant); + + const couldMatchRoot = useCouldMatchRoot([selectedTenant]); + + // derived during render so the trailing slash LeavesAutocomplete appends + // on blur (via onChange) is validated rather than the stale value + const partialResult = couldMatchRoot(prefix); + const prefixError = + partialResult !== true + ? partialResult + : finalEnabled && !prefix.startsWith(selectedTenant) + ? intl.formatMessage( + { id: 'leavesAutocomplete.mustStartWith.single' }, + { root: selectedTenant } + ) + : null; + + // build list of leaves out of live specs and storage mappings, + // scoped to the globally selected tenant + const leaves = useMemo( + () => + [ + ...liveSpecNames.map((name) => + // remove the catalog name leaving just the containing prefix + name.slice(0, name.lastIndexOf('/') + 1) + ), + ...storageMappings.map((sm) => sm.catalogPrefix), + ].filter((leaf) => leaf.startsWith(selectedTenant)), + [liveSpecNames, storageMappings, selectedTenant] + ); + + const handlePrefixChange = (value: string) => { + setPrefix(value); + setFinalEnabled(false); + }; + + const handlePrefixBlur = () => { + setFinalEnabled(true); + }; + + const resetForm = () => { + setDisplayName(''); + setPrefix(''); + setCapability('admin'); + setError(null); + setFinalEnabled(false); + }; + + const handleClose = () => { + onClose(); + resetForm(); + }; + + const handleCreate = async () => { + setError(null); + + if (!hasLength(displayName) || !hasLength(prefix)) { + return; + } + + if (!prefix.startsWith(selectedTenant)) { + setFinalEnabled(true); + return; + } + + const result = await createServiceAccount({ + displayName, + prefix, + capability, + }); + + if (result.error) { + setError(result.error.message); + return; + } + + handleClose(); + }; + + return ( + + Create Service Account + + + + + Create a non-login identity scoped to a catalog prefix. + The service account will be able to authenticate with + API keys. + + + {error ? ( + + {error} + + ) : null} + + setDisplayName(e.target.value)} + required + size="small" + fullWidth + placeholder="e.g. CI deploy bot" + /> + + + + + Capability + + + + + + + + + + + ); +} diff --git a/src/components/admin/ServiceAccounts/Row.tsx b/src/components/admin/ServiceAccounts/Row.tsx new file mode 100644 index 0000000000..592e08e614 --- /dev/null +++ b/src/components/admin/ServiceAccounts/Row.tsx @@ -0,0 +1,127 @@ +import type { ServiceAccount } from 'src/gql-types/graphql'; + +import { useState } from 'react'; + +import { + Chip, + IconButton, + TableCell, + TableRow, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; + +import { NavArrowDown, NavArrowRight } from 'iconoir-react'; + +import ServiceAccountActions from 'src/components/admin/ServiceAccounts/Actions'; +import ApiKeysRow from 'src/components/admin/ServiceAccounts/ApiKeysRow'; +import { getEntityTableRowSx } from 'src/context/Theme'; + +interface ServiceAccountRowProps { + serviceAccount: ServiceAccount; +} + +function ServiceAccountRow({ serviceAccount: sa }: ServiceAccountRowProps) { + const theme = useTheme(); + + const [expanded, setExpanded] = useState(false); + const isDisabled = Boolean(sa.disabledAt); + + return ( + <> + + + setExpanded((prev) => !prev)} + aria-label={expanded ? 'Collapse' : 'Expand'} + > + {expanded ? ( + + ) : ( + + )} + + + + + {sa.displayName} + + + + + {sa.prefix + .split(/(?<=[/_-])/) + .map((segment: string, i: number) => ( + + {segment} + + + ))} + + + + + + + + + + 0 ? 'info' : 'default'} + /> + + + + + + + + + + + + + {expanded ? ( + + ) : null} + + ); +} + +export default ServiceAccountRow; diff --git a/src/components/admin/ServiceAccounts/Table.tsx b/src/components/admin/ServiceAccounts/Table.tsx new file mode 100644 index 0000000000..adaef1fc16 --- /dev/null +++ b/src/components/admin/ServiceAccounts/Table.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; + +import { + Box, + Button, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHead, + TablePagination, + TableRow, + Typography, +} from '@mui/material'; + +import { FormattedMessage } from 'react-intl'; + +import { useServiceAccounts } from 'src/api/gql/serviceAccounts'; +import { CreateServiceAccountDialog } from 'src/components/admin/ServiceAccounts/CreateDialog'; +import ServiceAccountRow from 'src/components/admin/ServiceAccounts/Row'; +import { useCursorPagination } from 'src/hooks/useCursorPagination'; + +export function ServiceAccountsTable() { + const { currentPage, cursor, onPageChange } = useCursorPagination(); + const { serviceAccounts, fetching, error, pageInfo, pageSize } = + useServiceAccounts(cursor); + + const handlePageChange = (_event: any, page: number) => { + onPageChange(_event, page, pageInfo?.endCursor); + }; + + const [createOpen, setCreateOpen] = useState(false); + + return ( + + + + + + setCreateOpen(false)} + /> + + {error ? ( + + {error.message} + + ) : null} + + + + + + + Name + Prefix + Capability + API Keys + Status + + + + + + {fetching && serviceAccounts.length === 0 ? ( + + + + + + ) : serviceAccounts.length === 0 ? ( + + + + No service accounts found. + + setCreateOpen(true)} + sx={{ + 'cursor': 'pointer', + '&:hover': { + textDecoration: 'underline', + }, + }} + > + Create one now + + + + ) : ( + serviceAccounts.map((sa) => ( + + )) + )} + + + {pageInfo && serviceAccounts.length > 0 ? ( + + + { + const to = + from + serviceAccounts.length - 1; + return `${from}–${to}`; + }} + slotProps={{ + actions: { + previousButton: { + disabled: + !pageInfo.hasPreviousPage, + }, + nextButton: { + disabled: !pageInfo.hasNextPage, + }, + }, + }} + /> + + + ) : null} +
+
+
+ ); +} diff --git a/src/components/admin/ServiceAccounts/index.tsx b/src/components/admin/ServiceAccounts/index.tsx new file mode 100644 index 0000000000..b73ba5f5b0 --- /dev/null +++ b/src/components/admin/ServiceAccounts/index.tsx @@ -0,0 +1,42 @@ +import { Box, Divider, Stack, Typography } from '@mui/material'; + +import { authenticatedRoutes } from 'src/app/routes'; +import AccessToken from 'src/components/admin/Api/AccessToken'; +import { RefreshToken } from 'src/components/admin/Api/RefreshToken'; +import { ServiceAccountsTable } from 'src/components/admin/ServiceAccounts/Table'; +import AdminTabs from 'src/components/admin/Tabs'; +import usePageTitle from 'src/hooks/usePageTitle'; + +export function ServiceAccounts() { + usePageTitle({ + header: authenticatedRoutes.admin.serviceAccounts.title, + }); + + return ( + <> + + + + + + Service Accounts + + + + Service accounts provide non-login identities for CI/CD + pipelines, AI agents, and other programmatic + integrations, including the Kafka compatible API + “dekaf”. Each service account authenticates with API + keys and has scoped access to a single catalog prefix. + + + + + + + + + + + ); +} diff --git a/src/components/admin/Tabs.tsx b/src/components/admin/Tabs.tsx index ed07f90d03..1e80c97847 100644 --- a/src/components/admin/Tabs.tsx +++ b/src/components/admin/Tabs.tsx @@ -29,6 +29,11 @@ function AdminTabs() { }); } + response.push({ + labelMessageId: 'admin.tabs.serviceAccounts', + path: authenticatedRoutes.admin.serviceAccounts.fullPath, + }); + response.push({ labelMessageId: 'admin.tabs.api', path: authenticatedRoutes.admin.api.fullPath, diff --git a/src/context/Router/index.tsx b/src/context/Router/index.tsx index dfc5c94fc7..9ffd18a27c 100644 --- a/src/context/Router/index.tsx +++ b/src/context/Router/index.tsx @@ -13,6 +13,7 @@ import { authenticatedRoutes, unauthenticatedRoutes } from 'src/app/routes'; import AccessGrants from 'src/components/admin/AccessGrants'; import AdminApi from 'src/components/admin/Api'; import AdminBilling from 'src/components/admin/Billing'; +import { ServiceAccounts } from 'src/components/admin/ServiceAccounts'; import AdminSettings from 'src/components/admin/Settings'; import { ErrorImporting } from 'src/components/shared/ErrorImporting'; import HasSupportRoleGuard from 'src/components/shared/guards/SupportRole'; @@ -33,6 +34,7 @@ import DataPlaneAuthReq from 'src/pages/DataPlaneAuthReq'; import GqlExplorer from 'src/pages/dev/gqlExplorer'; import TestJsonForms from 'src/pages/dev/TestJsonForms'; import PageNotFound from 'src/pages/error/PageNotFound'; +import FlowctlAccessToken from 'src/pages/FlowctlAccessToken'; import HomePage from 'src/pages/Home'; import BasicLogin from 'src/pages/login/Basic'; import EnterpriseLogin from 'src/pages/login/Enterprise'; @@ -233,6 +235,11 @@ const router = createBrowserRouter( element={} /> + } + /> + } @@ -731,6 +738,16 @@ const router = createBrowserRouter( } /> + + + + } + /> null, AlertSubscription: (_data) => null, AlertTypeInfo: (_data) => null, + ApiKeyInfo: (_data) => null, InviteLink: (data) => null, LiveSpecRef: (_data) => null, PrefixRef: (_data) => null, @@ -87,6 +88,21 @@ function UrqlConfigProvider({ children }: BaseComponentProps) { revokeRefreshToken(_result, _args, cache) { invalidateQuery(cache, 'refreshTokens'); }, + createServiceAccount(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, + disableServiceAccount(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, + enableServiceAccount(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, + createApiKey(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, + revokeApiKey(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, }, }, }), diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index f3bf218a46..e3f0995a43 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -27,8 +27,12 @@ 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, +<<<<<<< HEAD "\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 query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n multiUse\n updatedAt\n uses\n # validFor\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": typeof types.RefreshTokensDocument, +>>>>>>> 72f13fa7 (service accoutns) "\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, @@ -55,8 +59,12 @@ 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, +<<<<<<< HEAD "\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 query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n multiUse\n updatedAt\n uses\n # validFor\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": types.RefreshTokensDocument, +>>>>>>> 72f13fa7 (service accoutns) "\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, @@ -139,6 +147,7 @@ export function graphql(source: "\n query LiveSpecsQuery($prefix: Prefix!, $a /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ +<<<<<<< HEAD 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. @@ -147,6 +156,12 @@ export function graphql(source: "\n mutation CreateRefreshToken(\n $de /** * 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 multiUse\n updatedAt\n uses\n # validFor\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 multiUse\n updatedAt\n uses\n # validFor\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. + */ +>>>>>>> 72f13fa7 (service accoutns) 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 231e807659..7b7209b231 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -362,6 +362,10 @@ export type CapabilityBit = | 'DeleteGrant' | 'JournalAppend' | 'JournalRead' +<<<<<<< HEAD +======= + | 'ManageServiceAccount' +>>>>>>> 72f13fa7 (service accoutns) | 'ModifyDataPlanePrivateNetworking' | 'SpecEdit' | 'ViewDataPlanePrivateNetworking'; @@ -560,6 +564,16 @@ export type CreateBillingSetupIntentPayload = { clientSecret: Scalars['String']['output']; }; +export type CreateServiceAccountTokenResult = { + __typename?: 'CreateServiceAccountTokenResult'; + id: Scalars['Id']['output']; + /** + * The bearer credential, returned exactly once. Present it as an + * `Authorization: Bearer` token or exchange it at `POST /api/v1/auth/token`. + */ + secret: Scalars['String']['output']; +}; + /** Result of creating a storage mapping. */ export type CreateStorageMappingResult = { __typename?: 'CreateStorageMappingResult'; @@ -1016,6 +1030,17 @@ export type LockFailure = { export type MutationRoot = { __typename?: 'MutationRoot'; + /** + * Add a user_grant to a service account. + * + * The caller must manage the service account (ManageServiceAccount on its + * catalog name) AND have CreateGrant on the granted prefix. The second + * requirement prevents a caller from extending an account's access beyond + * what they could grant anyone. (Human-user grant creation still lives in + * PostgREST; when it migrates to GraphQL it should gate on this same + * CreateGrant capability.) + */ + addServiceAccountGrant: Scalars['Boolean']['output']; /** * Creates a new alert subscription. Returns an error if there is already * an existing subscription for the same prefix and email address. @@ -1032,6 +1057,37 @@ export type MutationRoot = { /** Create a refresh token for the authenticated user. */ createRefreshToken: RefreshTokenResult; /** +<<<<<<< HEAD +======= + * Create a service account homed at the specified catalog name, seeded + * with the given user_grants. + * + * `catalogName` is a management anchor: admins of a prefix covering it + * may manage the account. It determines who may manage the account, not + * what the account may access. Access is determined solely by the + * account's user_grants, which may span multiple prefixes. + * + * The caller must have ManageServiceAccount on the catalog name AND + * CreateGrant on each granted prefix. Creates an auth.users row, an + * internal.service_accounts row, and a user_grants row per requested + * grant. + */ + createServiceAccount: ServiceAccount; + /** + * Mint a credential for a service account. + * + * The credential is a multi-use refresh token owned by the account: its + * secret never rotates and its validity window of `valid_for` slides with + * use, like any refresh token. Returns the token id and the bearer secret, + * which is returned exactly once and cannot be retrieved again. Present it + * as an `Authorization: Bearer` credential or exchange it for a 1-hour + * access token via `POST /api/v1/auth/token`. + * + * The caller must have ManageServiceAccount on the account's catalog name. + */ + createServiceAccountToken: CreateServiceAccountTokenResult; + /** +>>>>>>> 72f13fa7 (service accoutns) * Create a storage mapping for the given catalog prefix. * * This validates that the user has admin access to the catalog prefix, @@ -1056,6 +1112,7 @@ export type MutationRoot = { */ redeemInviteLink: RedeemInviteLinkResult; /** +<<<<<<< HEAD * Revoke a refresh token owned by the authenticated user. * * Rather than deleting the row, we zero its `valid_for` interval, which @@ -1063,6 +1120,37 @@ export type MutationRoot = { * Already-zeroed (revoked) tokens are treated as not found. */ revokeRefreshToken: Scalars['Boolean']['output']; +======= + * Remove a user_grant from a service account. + * + * The caller must manage the service account (ManageServiceAccount on its + * catalog name). Unlike addServiceAccountGrant, no capability on the + * grant's prefix is required: removal only ever narrows the account's + * access, so managers may remove ANY grant — including grants to + * prefixes they don't themselves administer. + */ + removeServiceAccountGrant: Scalars['Boolean']['output']; + /** + * 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']; + /** + * Revoke a service-account token. + * + * The caller must have ManageServiceAccount capability on the owning service + * account's catalog name. + * + * Rather than deleting the row, we zero its `valid_for` interval, which + * makes the token inert (it fails the exchange's expiry check and is + * excluded from listings) while preserving the audit trail. Already-revoked + * tokens are treated as not found. + */ + revokeServiceAccountToken: Scalars['Boolean']['output']; +>>>>>>> 72f13fa7 (service accoutns) setBillingPaymentMethod: BillingPaymentMethodPayload; /** * Check storage health for a given catalog prefix and storage definition. @@ -1123,6 +1211,13 @@ export type MutationRoot = { }; +export type MutationRootAddServiceAccountGrantArgs = { + capability: Capability; + catalogName: Scalars['Name']['input']; + prefix: Scalars['Prefix']['input']; +}; + + export type MutationRootCreateAlertSubscriptionArgs = { alertTypes?: InputMaybe>; detail?: InputMaybe; @@ -1151,6 +1246,22 @@ export type MutationRootCreateRefreshTokenArgs = { }; +<<<<<<< HEAD +======= +export type MutationRootCreateServiceAccountArgs = { + catalogName: Scalars['Name']['input']; + grants: Array; +}; + + +export type MutationRootCreateServiceAccountTokenArgs = { + catalogName: Scalars['Name']['input']; + detail: Scalars['String']['input']; + validFor: Scalars['String']['input']; +}; + + +>>>>>>> 72f13fa7 (service accoutns) export type MutationRootCreateStorageMappingArgs = { catalogPrefix: Scalars['Prefix']['input']; detail?: InputMaybe; @@ -1180,11 +1291,26 @@ export type MutationRootRedeemInviteLinkArgs = { }; +<<<<<<< HEAD +export type MutationRootRevokeRefreshTokenArgs = { +======= +export type MutationRootRemoveServiceAccountGrantArgs = { + catalogName: Scalars['Name']['input']; + prefix: Scalars['Prefix']['input']; +}; + + export type MutationRootRevokeRefreshTokenArgs = { id: Scalars['Id']['input']; }; +export type MutationRootRevokeServiceAccountTokenArgs = { +>>>>>>> 72f13fa7 (service accoutns) + id: Scalars['Id']['input']; +}; + + export type MutationRootSetBillingPaymentMethodArgs = { paymentMethodId: Scalars['String']['input']; tenant: Scalars['String']['input']; @@ -1543,6 +1669,15 @@ export type QueryRootRefreshTokensArgs = { }; +<<<<<<< HEAD +======= +export type QueryRootServiceAccountsArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + + +>>>>>>> 72f13fa7 (service accoutns) export type QueryRootStorageMappingsArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1618,6 +1753,57 @@ export type RepublishRequested = { receivedAt: Scalars['DateTime']['output']; }; +<<<<<<< HEAD +======= +export type ServiceAccount = { + __typename?: 'ServiceAccount'; + catalogName: Scalars['Name']['output']; + createdAt: Scalars['DateTime']['output']; + createdBy: Scalars['UUID']['output']; + lastUsedAt?: Maybe; + tokens: Array; + updatedAt: Scalars['DateTime']['output']; +}; + +export type ServiceAccountConnection = { + __typename?: 'ServiceAccountConnection'; + /** A list of edges. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +/** An edge in a connection. */ +export type ServiceAccountEdge = { + __typename?: 'ServiceAccountEdge'; + /** A cursor for use in pagination */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge */ + node: ServiceAccount; +}; + +/** A user_grant to seed a service account with at creation time. */ +export type ServiceAccountGrantInput = { + capability: Capability; + prefix: Scalars['Prefix']['input']; +}; + +/** + * A service-account credential: a multi-use refresh token owned by the account + * and minted by an administrator. The secret itself is returned only once at + * creation (see [`CreateServiceAccountTokenResult`]). + */ +export type ServiceAccountTokenInfo = { + __typename?: 'ServiceAccountTokenInfo'; + createdAt: Scalars['DateTime']['output']; + createdBy: Scalars['UUID']['output']; + detail?: Maybe; + expiresAt: Scalars['DateTime']['output']; + id: Scalars['Id']['output']; + lastUsedAt?: Maybe; +}; + +>>>>>>> 72f13fa7 (service accoutns) /** The shape of a connector status, which matches that of an ops::Log. */ export type ShardFailure = { __typename?: 'ShardFailure'; @@ -2031,6 +2217,7 @@ export type RefreshTokensQueryVariables = Exact<{ }>; +<<<<<<< HEAD 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<{ @@ -2042,6 +2229,10 @@ export type CreateRefreshTokenMutationVariables = Exact<{ export type CreateRefreshTokenMutation = { __typename?: 'MutationRoot', createRefreshToken: { __typename?: 'RefreshTokenResult', id: any, secret: string } }; +======= +export type RefreshTokensQuery = { __typename?: 'QueryRoot', refreshTokens: { __typename?: 'RefreshTokenInfoConnection', edges: Array<{ __typename?: 'RefreshTokenInfoEdge', cursor: string, node: { __typename?: 'RefreshTokenInfo', id: any, detail?: string | null, createdAt: any, multiUse: boolean, updatedAt: any, uses: number, expired: boolean } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; + +>>>>>>> 72f13fa7 (service accoutns) export type RevokeRefreshTokenMutationVariables = Exact<{ id: Scalars['Id']['input']; }>; @@ -2136,8 +2327,12 @@ 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; +<<<<<<< HEAD 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 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":"multiUse"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"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; +>>>>>>> 72f13fa7 (service accoutns) 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; diff --git a/src/gql-types/schema.graphql b/src/gql-types/schema.graphql index 66fbf3c76e..8498feabd1 100644 --- a/src/gql-types/schema.graphql +++ b/src/gql-types/schema.graphql @@ -347,6 +347,10 @@ enum CapabilityBit { DeleteGrant JournalAppend JournalRead +<<<<<<< HEAD +======= + ManageServiceAccount +>>>>>>> 72f13fa7 (service accoutns) ModifyDataPlanePrivateNetworking SpecEdit ViewDataPlanePrivateNetworking @@ -581,6 +585,16 @@ type CreateBillingSetupIntentPayload { clientSecret: String! } +type CreateServiceAccountTokenResult { + id: Id! + + """ + The bearer credential, returned exactly once. Present it as an + `Authorization: Bearer` token or exchange it at `POST /api/v1/auth/token`. + """ + secret: String! +} + """Result of creating a storage mapping.""" type CreateStorageMappingResult { """The catalog prefix for which the storage mapping was created.""" @@ -1038,6 +1052,18 @@ type LockFailure { } type MutationRoot { + """ + Add a user_grant to a service account. + + The caller must manage the service account (ManageServiceAccount on its + catalog name) AND have CreateGrant on the granted prefix. The second + requirement prevents a caller from extending an account's access beyond + what they could grant anyone. (Human-user grant creation still lives in + PostgREST; when it migrates to GraphQL it should gate on this same + CreateGrant capability.) + """ + addServiceAccountGrant(capability: Capability!, catalogName: Name!, prefix: Prefix!): Boolean! + """ Creates a new alert subscription. Returns an error if there is already an existing subscription for the same prefix and email address. @@ -1065,6 +1091,45 @@ type MutationRoot { ): RefreshTokenResult! """ +<<<<<<< HEAD +======= + Create a service account homed at the specified catalog name, seeded + with the given user_grants. + + `catalogName` is a management anchor: admins of a prefix covering it + may manage the account. It determines who may manage the account, not + what the account may access. Access is determined solely by the + account's user_grants, which may span multiple prefixes. + + The caller must have ManageServiceAccount on the catalog name AND + CreateGrant on each granted prefix. Creates an auth.users row, an + internal.service_accounts row, and a user_grants row per requested + grant. + """ + createServiceAccount(catalogName: Name!, grants: [ServiceAccountGrantInput!]!): ServiceAccount! + + """ + Mint a credential for a service account. + + The credential is a multi-use refresh token owned by the account: its + secret never rotates and its validity window of `valid_for` slides with + use, like any refresh token. Returns the token id and the bearer secret, + which is returned exactly once and cannot be retrieved again. Present it + as an `Authorization: Bearer` credential or exchange it for a 1-hour + access token via `POST /api/v1/auth/token`. + + The caller must have ManageServiceAccount on the account's catalog name. + """ + createServiceAccountToken( + catalogName: Name! + detail: String! + + """ISO 8601 duration for token validity (e.g. P90D, P1Y)""" + validFor: String! + ): CreateServiceAccountTokenResult! + + """ +>>>>>>> 72f13fa7 (service accoutns) Create a storage mapping for the given catalog prefix. This validates that the user has admin access to the catalog prefix, @@ -1095,6 +1160,7 @@ type MutationRoot { redeemInviteLink(token: UUID!): RedeemInviteLinkResult! """ +<<<<<<< HEAD Revoke a refresh token owned by the authenticated user. Rather than deleting the row, we zero its `valid_for` interval, which @@ -1102,6 +1168,39 @@ type MutationRoot { Already-zeroed (revoked) tokens are treated as not found. """ revokeRefreshToken(id: Id!): Boolean! +======= + Remove a user_grant from a service account. + + The caller must manage the service account (ManageServiceAccount on its + catalog name). Unlike addServiceAccountGrant, no capability on the + grant's prefix is required: removal only ever narrows the account's + access, so managers may remove ANY grant — including grants to + prefixes they don't themselves administer. + """ + removeServiceAccountGrant(catalogName: Name!, prefix: Prefix!): Boolean! + + """ + 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! + + """ + Revoke a service-account token. + + The caller must have ManageServiceAccount capability on the owning service + account's catalog name. + + Rather than deleting the row, we zero its `valid_for` interval, which + makes the token inert (it fails the exchange's expiry check and is + excluded from listings) while preserving the audit trail. Already-revoked + tokens are treated as not found. + """ + revokeServiceAccountToken(id: Id!): Boolean! +>>>>>>> 72f13fa7 (service accoutns) setBillingPaymentMethod(paymentMethodId: String!, tenant: String!): BillingPaymentMethodPayload! """ @@ -1446,6 +1545,10 @@ type QueryRoot { """List refresh tokens owned by the authenticated user.""" refreshTokens(after: String, first: Int): RefreshTokenInfoConnection! +<<<<<<< HEAD +======= + serviceAccounts(after: String, first: Int): ServiceAccountConnection! +>>>>>>> 72f13fa7 (service accoutns) """ Returns storage mappings accessible to the current user. @@ -1521,6 +1624,55 @@ type RepublishRequested { receivedAt: DateTime! } +<<<<<<< HEAD +======= +type ServiceAccount { + catalogName: Name! + createdAt: DateTime! + createdBy: UUID! + lastUsedAt: DateTime + tokens: [ServiceAccountTokenInfo!]! + updatedAt: DateTime! +} + +type ServiceAccountConnection { + """A list of edges.""" + edges: [ServiceAccountEdge!]! + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type ServiceAccountEdge { + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: ServiceAccount! +} + +"""A user_grant to seed a service account with at creation time.""" +input ServiceAccountGrantInput { + capability: Capability! + prefix: Prefix! +} + +""" +A service-account credential: a multi-use refresh token owned by the account +and minted by an administrator. The secret itself is returned only once at +creation (see [`CreateServiceAccountTokenResult`]). +""" +type ServiceAccountTokenInfo { + createdAt: DateTime! + createdBy: UUID! + detail: String + expiresAt: DateTime! + id: Id! + lastUsedAt: DateTime +} + +>>>>>>> 72f13fa7 (service accoutns) """The shape of a connector status, which matches that of an ops::Log.""" type ShardFailure { """ diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 17288b847b..377fd457d2 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -12,6 +12,7 @@ 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.`, +<<<<<<< HEAD '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`, @@ -31,6 +32,19 @@ export const AdminPage: Record = { '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.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.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.dialog.alert.tokenEncodingFailed': `An issue was encountered displaying your token. Please generate a new token.`, +>>>>>>> 72f13fa7 (service accoutns) '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.`, @@ -145,6 +159,7 @@ export const AdminPage: Record = { 'admin.tabs.users': `Account Access`, 'admin.tabs.notifications': `Notifications`, + 'admin.tabs.serviceAccounts': `Service Accounts`, 'admin.tabs.api': `CLI-API`, 'admin.tabs.billing': `Billing`, 'admin.tabs.settings': `Settings`, diff --git a/src/lang/en-US/RouteTitles.ts b/src/lang/en-US/RouteTitles.ts index 64c4469df1..f53969cca9 100644 --- a/src/lang/en-US/RouteTitles.ts +++ b/src/lang/en-US/RouteTitles.ts @@ -7,6 +7,7 @@ export const RouteTitles: Record = { 'routeTitle.admin.accessGrants': `Access Grants`, 'routeTitle.admin.api': `CLI - API`, 'routeTitle.admin.billing': `Billing`, + 'routeTitle.admin.serviceAccounts': `Service Accounts`, 'routeTitle.admin.settings': `Settings`, 'routeTitle.captureCreate': `Create Capture`, 'routeTitle.captureDetails': `Capture Details`, @@ -20,6 +21,7 @@ export const RouteTitles: Record = { 'routeTitle.details': `Details`, 'routeTitle.error.entityNotFound': `Entity Not Found`, 'routeTitle.error.pageNotFound': `Page Not Found`, + 'routeTitle.flowctl.accessToken': `flowctl Access Token`, 'routeTitle.loginLoading': `Checking Credentials`, 'routeTitle.noGrants': `Signed Up`, 'routeTitle.legal': `Legal`, diff --git a/src/pages/FlowctlAccessToken.tsx b/src/pages/FlowctlAccessToken.tsx new file mode 100644 index 0000000000..06f25c7e5c --- /dev/null +++ b/src/pages/FlowctlAccessToken.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; + +import { Box, Button, Stack, Typography } from '@mui/material'; + +import { CheckCircle, Copy } from 'iconoir-react'; + +import { authenticatedRoutes } from 'src/app/routes'; +import AlertBox from 'src/components/shared/AlertBox'; +import { useUserStore } from 'src/context/User/useUserContextStore'; +import usePageTitle from 'src/hooks/usePageTitle'; +import { logRocketEvent } from 'src/services/shared'; + +function FlowctlAccessToken() { + const accessToken = useUserStore((state) => state.session?.access_token); + const [isCopied, setIsCopied] = useState(false); + + usePageTitle({ + header: authenticatedRoutes.flowctl.accessToken.title, + }); + + const handleCopy = () => { + if (!accessToken) { + return; + } + + navigator.clipboard.writeText(accessToken).then( + () => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); + }, + () => { + setIsCopied(false); + logRocketEvent('Error_Silent', { + component: 'FlowctlAccessToken', + operation: 'copyAccessToken', + }); + } + ); + }; + + return ( + + + + Flowctl access token + + + {accessToken ? ( + + + + Paste this token into your terminal to complete + flowctl login. + + + ) : ( + + No access token is available. Refresh the page or log in + again, then retry flowctl login. + + )} + + + ); +} + +export default FlowctlAccessToken; From 85af56ae50652b338b2f36b20259cbc931c01098 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 18 Jun 2026 22:35:09 -0400 Subject: [PATCH 2/9] merged --- src/api/gql/serviceAccounts.ts | 85 ++++------- .../Api/RefreshToken/Dialog/Description.tsx | 38 ----- .../admin/Api/RefreshToken/Dialog/Title.tsx | 54 ------- .../admin/Api/RefreshToken/index.tsx | 4 - .../admin/ServiceAccounts/Actions.tsx | 143 ------------------ .../admin/ServiceAccounts/ApiKeysRow.tsx | 40 ++--- .../ServiceAccounts/CreateApiKeyDialog.tsx | 22 +-- .../admin/ServiceAccounts/CreateDialog.tsx | 55 ++++--- src/components/admin/ServiceAccounts/Row.tsx | 65 +++----- .../admin/ServiceAccounts/Table.tsx | 15 +- src/gql-types/gql.ts | 35 +++-- src/gql-types/graphql.ts | 75 +++++---- src/gql-types/schema.graphql | 22 --- src/lang/en-US/AdminPage.ts | 15 -- 14 files changed, 185 insertions(+), 483 deletions(-) delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/Description.tsx delete mode 100644 src/components/admin/Api/RefreshToken/Dialog/Title.tsx delete mode 100644 src/components/admin/ServiceAccounts/Actions.tsx diff --git a/src/api/gql/serviceAccounts.ts b/src/api/gql/serviceAccounts.ts index 362a373d2f..7a1bd56e7a 100644 --- a/src/api/gql/serviceAccounts.ts +++ b/src/api/gql/serviceAccounts.ts @@ -15,18 +15,14 @@ const SERVICE_ACCOUNTS_QUERY = graphql(` serviceAccounts(first: $first, after: $after) { edges { node { - id - displayName - prefix - capability - createdBy + catalogName createdAt + createdBy updatedAt - disabledAt lastUsedAt - apiKeys { + tokens { id - label + detail createdAt createdBy expiresAt @@ -43,7 +39,7 @@ const SERVICE_ACCOUNTS_QUERY = graphql(` `); export function useServiceAccounts(afterCursor?: string) { - const [{ fetching, data, error }, reexecute] = useQuery({ + const [{ fetching, data, error }] = useQuery({ query: SERVICE_ACCOUNTS_QUERY, variables: { first: SERVICE_ACCOUNTS_PAGE_SIZE, @@ -66,52 +62,35 @@ export function useServiceAccounts(afterCursor?: string) { error, pageInfo, pageSize: SERVICE_ACCOUNTS_PAGE_SIZE, - reexecute, }; } -export const CREATE_SERVICE_ACCOUNT = graphql(` +// A service account is homed at `catalogName` (its management anchor) and +// seeded with one or more grants, each granting a capability on a prefix. +const CREATE_SERVICE_ACCOUNT = graphql(` mutation CreateServiceAccount( - $prefix: Prefix! - $capability: Capability! - $displayName: String! + $catalogName: Name! + $grants: [ServiceAccountGrantInput!]! ) { - createServiceAccount( - prefix: $prefix - capability: $capability - displayName: $displayName - ) { - id - displayName - prefix - capability + createServiceAccount(catalogName: $catalogName, grants: $grants) { + catalogName createdAt createdBy } } `); -export const DISABLE_SERVICE_ACCOUNT = graphql(` - mutation DisableServiceAccount($id: UUID!) { - disableServiceAccount(id: $id) - } -`); - -export const ENABLE_SERVICE_ACCOUNT = graphql(` - mutation EnableServiceAccount($id: UUID!) { - enableServiceAccount(id: $id) - } -`); - -export const CREATE_API_KEY = graphql(` - mutation CreateApiKey( - $serviceAccountId: UUID! - $label: String! +// Mints a credential (refresh token) owned by the account. The secret is +// returned exactly once and cannot be retrieved again. +const CREATE_SERVICE_ACCOUNT_TOKEN = graphql(` + mutation CreateServiceAccountToken( + $catalogName: Name! + $detail: String! $validFor: String! ) { - createApiKey( - serviceAccountId: $serviceAccountId - label: $label + createServiceAccountToken( + catalogName: $catalogName + detail: $detail validFor: $validFor ) { id @@ -120,9 +99,9 @@ export const CREATE_API_KEY = graphql(` } `); -export const REVOKE_API_KEY = graphql(` - mutation RevokeApiKey($id: Id!) { - revokeApiKey(id: $id) +const REVOKE_SERVICE_ACCOUNT_TOKEN = graphql(` + mutation RevokeServiceAccountToken($id: Id!) { + revokeServiceAccountToken(id: $id) } `); @@ -130,18 +109,10 @@ export function useCreateServiceAccount() { return useMutation(CREATE_SERVICE_ACCOUNT); } -export function useDisableServiceAccount() { - return useMutation(DISABLE_SERVICE_ACCOUNT); -} - -export function useEnableServiceAccount() { - return useMutation(ENABLE_SERVICE_ACCOUNT); -} - -export function useCreateApiKey() { - return useMutation(CREATE_API_KEY); +export function useCreateServiceAccountToken() { + return useMutation(CREATE_SERVICE_ACCOUNT_TOKEN); } -export function useRevokeApiKey() { - return useMutation(REVOKE_API_KEY); +export function useRevokeServiceAccountToken() { + return useMutation(REVOKE_SERVICE_ACCOUNT_TOKEN); } 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/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/index.tsx b/src/components/admin/Api/RefreshToken/index.tsx index 00942254cb..f45311ed73 100644 --- a/src/components/admin/Api/RefreshToken/index.tsx +++ b/src/components/admin/Api/RefreshToken/index.tsx @@ -15,11 +15,7 @@ export function RefreshToken() { fontWeight: '400', }} > -<<<<<<< HEAD -======= - ->>>>>>> 72f13fa7 (service accoutns)
diff --git a/src/components/admin/ServiceAccounts/Actions.tsx b/src/components/admin/ServiceAccounts/Actions.tsx deleted file mode 100644 index e8b4914b1a..0000000000 --- a/src/components/admin/ServiceAccounts/Actions.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { ServiceAccount } from 'src/gql-types/graphql'; - -import { useRef, useState } from 'react'; - -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Stack, - Typography, -} from '@mui/material'; - -import { - useDisableServiceAccount, - useEnableServiceAccount, -} from 'src/api/gql/serviceAccounts'; -import Error from 'src/components/shared/Error'; - -interface Props { - serviceAccount: Pick; -} - -function ServiceAccountActions({ serviceAccount }: Props) { - const isDisabled = Boolean(serviceAccount.disabledAt); - - const [confirmOpen, setConfirmOpen] = useState(false); - const [error, setError] = useState(null); - const disabledAtOpen = useRef(isDisabled); - - const [{ fetching: disabling }, disableServiceAccount] = - useDisableServiceAccount(); - const [{ fetching: enabling }, enableServiceAccount] = - useEnableServiceAccount(); - - const busy = disabling || enabling; - - const handleToggle = async () => { - setError(null); - - if (disabledAtOpen.current) { - const result = await enableServiceAccount({ - id: serviceAccount.id, - }); - - if (result.error) { - setError(result.error.message); - return; - } - - setConfirmOpen(false); - } else { - const result = await disableServiceAccount({ - id: serviceAccount.id, - }); - - if (result.error) { - setError(result.error.message); - return; - } - - setConfirmOpen(false); - } - }; - - return ( - <> - - - setConfirmOpen(false)} - maxWidth="xs" - fullWidth - slotProps={{ - transition: { onExited: () => setError(null) }, - }} - > - - {disabledAtOpen.current - ? 'Restore Service Account' - : 'Disable Service Account'} - - - - - {error ? ( - - ) : null} - - {disabledAtOpen.current - ? `Restore "${serviceAccount.displayName}"? This will not restore previously revoked API keys — you must create new ones.` - : `Disable "${serviceAccount.displayName}"?`} - - - {!disabledAtOpen.current ? ( - - All active API keys will be permanently revoked. - You will need to create new keys if you restore - this account. - - ) : null} - - - - - - - - - - ); -} - -export default ServiceAccountActions; diff --git a/src/components/admin/ServiceAccounts/ApiKeysRow.tsx b/src/components/admin/ServiceAccounts/ApiKeysRow.tsx index 5301f9d841..b4966b67e4 100644 --- a/src/components/admin/ServiceAccounts/ApiKeysRow.tsx +++ b/src/components/admin/ServiceAccounts/ApiKeysRow.tsx @@ -1,4 +1,7 @@ -import type { ApiKeyInfo, ServiceAccount } from 'src/gql-types/graphql'; +import type { + ServiceAccount, + ServiceAccountTokenInfo, +} from 'src/gql-types/graphql'; import { useState } from 'react'; @@ -21,32 +24,32 @@ import { import { DateTime } from 'luxon'; -import { useRevokeApiKey } from 'src/api/gql/serviceAccounts'; +import { useRevokeServiceAccountToken } from 'src/api/gql/serviceAccounts'; import CreateApiKeyDialog from 'src/components/admin/ServiceAccounts/CreateApiKeyDialog'; import Error from 'src/components/shared/Error'; interface Props { - serviceAccount: Pick; - isDisabled: boolean; + serviceAccount: Pick; } function isExpired(expiresAt: string): boolean { return DateTime.fromISO(expiresAt) < DateTime.now(); } -function ApiKeyRow({ apiKey }: { apiKey: ApiKeyInfo }) { - const [, revokeApiKey] = useRevokeApiKey(); +function ApiKeyRow({ apiKey }: { apiKey: ServiceAccountTokenInfo }) { + const [, revokeServiceAccountToken] = useRevokeServiceAccountToken(); const [confirmOpen, setConfirmOpen] = useState(false); const [revoking, setRevoking] = useState(false); const [error, setError] = useState(null); const expired = isExpired(apiKey.expiresAt); + const label = apiKey.detail ?? 'Unnamed key'; const handleRevoke = async () => { setError(null); setRevoking(true); - const result = await revokeApiKey({ id: apiKey.id }); + const result = await revokeServiceAccountToken({ id: apiKey.id }); setRevoking(false); @@ -61,7 +64,7 @@ function ApiKeyRow({ apiKey }: { apiKey: ApiKeyInfo }) { return ( - {apiKey.label} + {label} @@ -130,7 +133,7 @@ function ApiKeyRow({ apiKey }: { apiKey: ApiKeyInfo }) { /> ) : null} - {`Revoke "${apiKey.label}"? This action is permanent.`} + {`Revoke "${label}"? This action is permanent.`} {apiKey.lastUsedAt && DateTime.fromISO(apiKey.lastUsedAt) > @@ -167,10 +170,10 @@ function ApiKeyRow({ apiKey }: { apiKey: ApiKeyInfo }) { ); } -function ApiKeysRow({ serviceAccount, isDisabled }: Props) { +function ApiKeysRow({ serviceAccount }: Props) { return ( - + API Keys - {!isDisabled ? ( - - ) : null} + - {serviceAccount.apiKeys.length === 0 ? ( + {serviceAccount.tokens.length === 0 ? ( No API keys. Create one to enable programmatic authentication. @@ -207,8 +207,8 @@ function ApiKeysRow({ serviceAccount, isDisabled }: Props) { - {serviceAccount.apiKeys.map((key) => ( - + {serviceAccount.tokens.map((token) => ( + ))} diff --git a/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx index ff8d093abf..96a2ba3a3f 100644 --- a/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx +++ b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx @@ -15,7 +15,7 @@ import { Typography, } from '@mui/material'; -import { useCreateApiKey } from 'src/api/gql/serviceAccounts'; +import { useCreateServiceAccountToken } from 'src/api/gql/serviceAccounts'; import SingleLineCode from 'src/components/content/SingleLineCode'; import AlertBox from 'src/components/shared/AlertBox'; import { hasLength } from 'src/utils/misc-utils'; @@ -27,18 +27,18 @@ const VALIDITY_OPTIONS = [ ]; interface Props { - serviceAccountId: string; - serviceAccountName: string; + catalogName: string; } -function CreateApiKeyDialog({ serviceAccountId, serviceAccountName }: Props) { +function CreateApiKeyDialog({ catalogName }: Props) { const [open, setOpen] = useState(false); const [label, setLabel] = useState(''); const [validFor, setValidFor] = useState('P90D'); const [secret, setSecret] = useState(null); const [error, setError] = useState(null); - const [{ fetching }, createApiKey] = useCreateApiKey(); + const [{ fetching }, createServiceAccountToken] = + useCreateServiceAccountToken(); const resetForm = () => { setLabel(''); @@ -58,13 +58,13 @@ function CreateApiKeyDialog({ serviceAccountId, serviceAccountName }: Props) { return; } - const result = await createApiKey({ - serviceAccountId, - label, + const result = await createServiceAccountToken({ + catalogName, + detail: label, validFor, }); - if (result.error || !result.data?.createApiKey) { + if (result.error || !result.data?.createServiceAccountToken) { setError( result.error?.message ?? 'There was an error creating the API key.' @@ -72,7 +72,7 @@ function CreateApiKeyDialog({ serviceAccountId, serviceAccountName }: Props) { return; } - setSecret(result.data.createApiKey.secret); + setSecret(result.data.createServiceAccountToken.secret); }; return ( @@ -95,7 +95,7 @@ function CreateApiKeyDialog({ serviceAccountId, serviceAccountName }: Props) { }} > - {`Create API Key for ${serviceAccountName}`} + {`Create API Key for ${catalogName}`} diff --git a/src/components/admin/ServiceAccounts/CreateDialog.tsx b/src/components/admin/ServiceAccounts/CreateDialog.tsx index 447c8aaa55..a49dd7548b 100644 --- a/src/components/admin/ServiceAccounts/CreateDialog.tsx +++ b/src/components/admin/ServiceAccounts/CreateDialog.tsx @@ -23,7 +23,7 @@ import { useStorageMappings } from 'src/api/gql/storageMappings'; import AlertBox from 'src/components/shared/AlertBox'; import { useCouldMatchRoot } from 'src/components/shared/LeavesAutocomplete'; import { LeavesAutocomplete } from 'src/components/shared/LeavesAutocomplete/LeavesAutocomplete'; -import { useTenantStore } from 'src/stores/Tenant/Store'; +import { useTenantStore } from 'src/stores/Tenant'; import { hasLength } from 'src/utils/misc-utils'; // 'none' and 'write' intentionally omitted @@ -40,7 +40,7 @@ export function CreateServiceAccountDialog({ }: CreateServiceAccountDialogProps) { const intl = useIntl(); - const [displayName, setDisplayName] = useState(''); + const [catalogName, setCatalogName] = useState(''); const [prefix, setPrefix] = useState(''); const [capability, setCapability] = useState('admin'); const [error, setError] = useState(null); @@ -69,6 +69,16 @@ export function CreateServiceAccountDialog({ ) : null; + const catalogNameError = + finalEnabled && + hasLength(catalogName) && + !catalogName.startsWith(selectedTenant) + ? intl.formatMessage( + { id: 'leavesAutocomplete.mustStartWith.single' }, + { root: selectedTenant } + ) + : null; + // build list of leaves out of live specs and storage mappings, // scoped to the globally selected tenant const leaves = useMemo( @@ -93,7 +103,7 @@ export function CreateServiceAccountDialog({ }; const resetForm = () => { - setDisplayName(''); + setCatalogName(''); setPrefix(''); setCapability('admin'); setError(null); @@ -108,19 +118,21 @@ export function CreateServiceAccountDialog({ const handleCreate = async () => { setError(null); - if (!hasLength(displayName) || !hasLength(prefix)) { + if (!hasLength(catalogName) || !hasLength(prefix)) { return; } - if (!prefix.startsWith(selectedTenant)) { + if ( + !catalogName.startsWith(selectedTenant) || + !prefix.startsWith(selectedTenant) + ) { setFinalEnabled(true); return; } const result = await createServiceAccount({ - displayName, - prefix, - capability, + catalogName, + grants: [{ prefix, capability }], }); if (result.error) { @@ -138,9 +150,9 @@ export function CreateServiceAccountDialog({ - Create a non-login identity scoped to a catalog prefix. - The service account will be able to authenticate with - API keys. + Create a non-login identity homed at a catalog name and + granted access to a prefix. The service account will be + able to authenticate with API keys. {error ? ( @@ -150,13 +162,19 @@ export function CreateServiceAccountDialog({ ) : null} setDisplayName(e.target.value)} + label="Name" + value={catalogName} + onChange={(e) => setCatalogName(e.target.value)} + onBlur={() => setFinalEnabled(true)} required size="small" fullWidth - placeholder="e.g. CI deploy bot" + placeholder={`${selectedTenant}ci-deploy-bot`} + error={Boolean(catalogNameError)} + helperText={ + catalogNameError ?? + 'The catalog name that anchors this account.' + } /> diff --git a/src/components/admin/ServiceAccounts/Row.tsx b/src/components/admin/ServiceAccounts/Row.tsx index 592e08e614..d8bbd97205 100644 --- a/src/components/admin/ServiceAccounts/Row.tsx +++ b/src/components/admin/ServiceAccounts/Row.tsx @@ -14,7 +14,8 @@ import { import { NavArrowDown, NavArrowRight } from 'iconoir-react'; -import ServiceAccountActions from 'src/components/admin/ServiceAccounts/Actions'; +import { DateTime } from 'luxon'; + import ApiKeysRow from 'src/components/admin/ServiceAccounts/ApiKeysRow'; import { getEntityTableRowSx } from 'src/context/Theme'; @@ -26,19 +27,12 @@ function ServiceAccountRow({ serviceAccount: sa }: ServiceAccountRowProps) { const theme = useTheme(); const [expanded, setExpanded] = useState(false); - const isDisabled = Boolean(sa.disabledAt); + + const tokenCount = sa.tokens.length; return ( <> - + - - {sa.displayName} - - - + - {sa.prefix + {sa.catalogName .split(/(?<=[/_-])/) .map((segment: string, i: number) => ( @@ -84,42 +74,35 @@ function ServiceAccountRow({ serviceAccount: sa }: ServiceAccountRowProps) { - + + {DateTime.fromISO(sa.createdAt).toLocaleString( + DateTime.DATE_MED + )} + + + + + + {sa.lastUsedAt + ? DateTime.fromISO(sa.lastUsedAt).toRelative() + : 'Never'} + 0 ? 'info' : 'default'} + color={tokenCount > 0 ? 'info' : 'default'} /> - - - - - - - - - {expanded ? ( - - ) : null} + {expanded ? : null} ); } diff --git a/src/components/admin/ServiceAccounts/Table.tsx b/src/components/admin/ServiceAccounts/Table.tsx index adaef1fc16..89ed60742f 100644 --- a/src/components/admin/ServiceAccounts/Table.tsx +++ b/src/components/admin/ServiceAccounts/Table.tsx @@ -22,6 +22,9 @@ import { CreateServiceAccountDialog } from 'src/components/admin/ServiceAccounts import ServiceAccountRow from 'src/components/admin/ServiceAccounts/Row'; import { useCursorPagination } from 'src/hooks/useCursorPagination'; +// expand toggle + Name + Created + Last Used + API Keys +const COLUMN_COUNT = 5; + export function ServiceAccountsTable() { const { currentPage, cursor, onPageChange } = useCursorPagination(); const { serviceAccounts, fetching, error, pageInfo, pageSize } = @@ -61,11 +64,9 @@ export function ServiceAccountsTable() { Name - Prefix - Capability + Created + Last Used API Keys - Status - @@ -73,7 +74,7 @@ export function ServiceAccountsTable() { {fetching && serviceAccounts.length === 0 ? ( @@ -82,7 +83,7 @@ export function ServiceAccountsTable() { ) : serviceAccounts.length === 0 ? ( @@ -107,7 +108,7 @@ export function ServiceAccountsTable() { ) : ( serviceAccounts.map((sa) => ( )) diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index e3f0995a43..eecb1bf409 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -27,13 +27,13 @@ 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, -<<<<<<< HEAD "\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 query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n multiUse\n updatedAt\n uses\n # validFor\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": typeof types.RefreshTokensDocument, ->>>>>>> 72f13fa7 (service accoutns) "\n mutation RevokeRefreshToken($id: Id!) {\n revokeRefreshToken(id: $id)\n }\n": typeof types.RevokeRefreshTokenDocument, + "\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": typeof types.ServiceAccountsDocument, + "\n mutation CreateServiceAccount(\n $catalogName: Name!\n $grants: [ServiceAccountGrantInput!]!\n ) {\n createServiceAccount(catalogName: $catalogName, grants: $grants) {\n catalogName\n createdAt\n createdBy\n }\n }\n": typeof types.CreateServiceAccountDocument, + "\n mutation CreateServiceAccountToken(\n $catalogName: Name!\n $detail: String!\n $validFor: String!\n ) {\n createServiceAccountToken(\n catalogName: $catalogName\n detail: $detail\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n": typeof types.CreateServiceAccountTokenDocument, + "\n mutation RevokeServiceAccountToken($id: Id!) {\n revokeServiceAccountToken(id: $id)\n }\n": typeof types.RevokeServiceAccountTokenDocument, "\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, @@ -59,13 +59,13 @@ 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, -<<<<<<< HEAD "\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 query RefreshTokens($first: Int, $after: String) {\n refreshTokens(first: $first, after: $after) {\n edges {\n node {\n id\n detail\n createdAt\n multiUse\n updatedAt\n uses\n # validFor\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": types.RefreshTokensDocument, ->>>>>>> 72f13fa7 (service accoutns) "\n mutation RevokeRefreshToken($id: Id!) {\n revokeRefreshToken(id: $id)\n }\n": types.RevokeRefreshTokenDocument, + "\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": types.ServiceAccountsDocument, + "\n mutation CreateServiceAccount(\n $catalogName: Name!\n $grants: [ServiceAccountGrantInput!]!\n ) {\n createServiceAccount(catalogName: $catalogName, grants: $grants) {\n catalogName\n createdAt\n createdBy\n }\n }\n": types.CreateServiceAccountDocument, + "\n mutation CreateServiceAccountToken(\n $catalogName: Name!\n $detail: String!\n $validFor: String!\n ) {\n createServiceAccountToken(\n catalogName: $catalogName\n detail: $detail\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n": types.CreateServiceAccountTokenDocument, + "\n mutation RevokeServiceAccountToken($id: Id!) {\n revokeServiceAccountToken(id: $id)\n }\n": types.RevokeServiceAccountTokenDocument, "\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, @@ -147,7 +147,6 @@ export function graphql(source: "\n query LiveSpecsQuery($prefix: Prefix!, $a /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -<<<<<<< HEAD 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. @@ -156,13 +155,23 @@ export function graphql(source: "\n mutation CreateRefreshToken(\n $de /** * 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 multiUse\n updatedAt\n uses\n # validFor\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 multiUse\n updatedAt\n uses\n # validFor\n expired\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"]; +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. */ ->>>>>>> 72f13fa7 (service accoutns) -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"]; +export function graphql(source: "\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"): (typeof documents)["\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\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 CreateServiceAccount(\n $catalogName: Name!\n $grants: [ServiceAccountGrantInput!]!\n ) {\n createServiceAccount(catalogName: $catalogName, grants: $grants) {\n catalogName\n createdAt\n createdBy\n }\n }\n"): (typeof documents)["\n mutation CreateServiceAccount(\n $catalogName: Name!\n $grants: [ServiceAccountGrantInput!]!\n ) {\n createServiceAccount(catalogName: $catalogName, grants: $grants) {\n catalogName\n createdAt\n createdBy\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 CreateServiceAccountToken(\n $catalogName: Name!\n $detail: String!\n $validFor: String!\n ) {\n createServiceAccountToken(\n catalogName: $catalogName\n detail: $detail\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n"): (typeof documents)["\n mutation CreateServiceAccountToken(\n $catalogName: Name!\n $detail: String!\n $validFor: String!\n ) {\n createServiceAccountToken(\n catalogName: $catalogName\n detail: $detail\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 RevokeServiceAccountToken($id: Id!) {\n revokeServiceAccountToken(id: $id)\n }\n"): (typeof documents)["\n mutation RevokeServiceAccountToken($id: Id!) {\n revokeServiceAccountToken(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 7b7209b231..b786e5797a 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -362,10 +362,7 @@ export type CapabilityBit = | 'DeleteGrant' | 'JournalAppend' | 'JournalRead' -<<<<<<< HEAD -======= | 'ManageServiceAccount' ->>>>>>> 72f13fa7 (service accoutns) | 'ModifyDataPlanePrivateNetworking' | 'SpecEdit' | 'ViewDataPlanePrivateNetworking'; @@ -1057,8 +1054,6 @@ export type MutationRoot = { /** Create a refresh token for the authenticated user. */ createRefreshToken: RefreshTokenResult; /** -<<<<<<< HEAD -======= * Create a service account homed at the specified catalog name, seeded * with the given user_grants. * @@ -1087,7 +1082,6 @@ export type MutationRoot = { */ createServiceAccountToken: CreateServiceAccountTokenResult; /** ->>>>>>> 72f13fa7 (service accoutns) * Create a storage mapping for the given catalog prefix. * * This validates that the user has admin access to the catalog prefix, @@ -1112,15 +1106,6 @@ export type MutationRoot = { */ redeemInviteLink: RedeemInviteLinkResult; /** -<<<<<<< HEAD - * 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']; -======= * Remove a user_grant from a service account. * * The caller must manage the service account (ManageServiceAccount on its @@ -1150,7 +1135,6 @@ export type MutationRoot = { * tokens are treated as not found. */ revokeServiceAccountToken: Scalars['Boolean']['output']; ->>>>>>> 72f13fa7 (service accoutns) setBillingPaymentMethod: BillingPaymentMethodPayload; /** * Check storage health for a given catalog prefix and storage definition. @@ -1246,8 +1230,6 @@ export type MutationRootCreateRefreshTokenArgs = { }; -<<<<<<< HEAD -======= export type MutationRootCreateServiceAccountArgs = { catalogName: Scalars['Name']['input']; grants: Array; @@ -1261,7 +1243,6 @@ export type MutationRootCreateServiceAccountTokenArgs = { }; ->>>>>>> 72f13fa7 (service accoutns) export type MutationRootCreateStorageMappingArgs = { catalogPrefix: Scalars['Prefix']['input']; detail?: InputMaybe; @@ -1291,9 +1272,6 @@ export type MutationRootRedeemInviteLinkArgs = { }; -<<<<<<< HEAD -export type MutationRootRevokeRefreshTokenArgs = { -======= export type MutationRootRemoveServiceAccountGrantArgs = { catalogName: Scalars['Name']['input']; prefix: Scalars['Prefix']['input']; @@ -1306,7 +1284,6 @@ export type MutationRootRevokeRefreshTokenArgs = { export type MutationRootRevokeServiceAccountTokenArgs = { ->>>>>>> 72f13fa7 (service accoutns) id: Scalars['Id']['input']; }; @@ -1580,6 +1557,7 @@ export type QueryRoot = { prefixes: PrefixRefConnection; /** List refresh tokens owned by the authenticated user. */ refreshTokens: RefreshTokenInfoConnection; + serviceAccounts: ServiceAccountConnection; /** * Returns storage mappings accessible to the current user. * @@ -1669,15 +1647,12 @@ export type QueryRootRefreshTokensArgs = { }; -<<<<<<< HEAD -======= export type QueryRootServiceAccountsArgs = { after?: InputMaybe; first?: InputMaybe; }; ->>>>>>> 72f13fa7 (service accoutns) export type QueryRootStorageMappingsArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1753,8 +1728,6 @@ export type RepublishRequested = { receivedAt: Scalars['DateTime']['output']; }; -<<<<<<< HEAD -======= export type ServiceAccount = { __typename?: 'ServiceAccount'; catalogName: Scalars['Name']['output']; @@ -1803,7 +1776,6 @@ export type ServiceAccountTokenInfo = { lastUsedAt?: Maybe; }; ->>>>>>> 72f13fa7 (service accoutns) /** The shape of a connector status, which matches that of an ops::Log. */ export type ShardFailure = { __typename?: 'ShardFailure'; @@ -2217,7 +2189,6 @@ export type RefreshTokensQueryVariables = Exact<{ }>; -<<<<<<< HEAD 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<{ @@ -2229,10 +2200,6 @@ export type CreateRefreshTokenMutationVariables = Exact<{ export type CreateRefreshTokenMutation = { __typename?: 'MutationRoot', createRefreshToken: { __typename?: 'RefreshTokenResult', id: any, secret: string } }; -======= -export type RefreshTokensQuery = { __typename?: 'QueryRoot', refreshTokens: { __typename?: 'RefreshTokenInfoConnection', edges: Array<{ __typename?: 'RefreshTokenInfoEdge', cursor: string, node: { __typename?: 'RefreshTokenInfo', id: any, detail?: string | null, createdAt: any, multiUse: boolean, updatedAt: any, uses: number, expired: boolean } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; - ->>>>>>> 72f13fa7 (service accoutns) export type RevokeRefreshTokenMutationVariables = Exact<{ id: Scalars['Id']['input']; }>; @@ -2240,6 +2207,38 @@ export type RevokeRefreshTokenMutationVariables = Exact<{ export type RevokeRefreshTokenMutation = { __typename?: 'MutationRoot', revokeRefreshToken: boolean }; +export type ServiceAccountsQueryVariables = Exact<{ + first?: InputMaybe; + after?: InputMaybe; +}>; + + +export type ServiceAccountsQuery = { __typename?: 'QueryRoot', serviceAccounts: { __typename?: 'ServiceAccountConnection', edges: Array<{ __typename?: 'ServiceAccountEdge', cursor: string, node: { __typename?: 'ServiceAccount', catalogName: any, createdAt: any, createdBy: any, updatedAt: any, lastUsedAt?: any | null, tokens: Array<{ __typename?: 'ServiceAccountTokenInfo', id: any, detail?: string | null, createdAt: any, createdBy: any, expiresAt: any, lastUsedAt?: any | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; + +export type CreateServiceAccountMutationVariables = Exact<{ + catalogName: Scalars['Name']['input']; + grants: Array | ServiceAccountGrantInput; +}>; + + +export type CreateServiceAccountMutation = { __typename?: 'MutationRoot', createServiceAccount: { __typename?: 'ServiceAccount', catalogName: any, createdAt: any, createdBy: any } }; + +export type CreateServiceAccountTokenMutationVariables = Exact<{ + catalogName: Scalars['Name']['input']; + detail: Scalars['String']['input']; + validFor: Scalars['String']['input']; +}>; + + +export type CreateServiceAccountTokenMutation = { __typename?: 'MutationRoot', createServiceAccountToken: { __typename?: 'CreateServiceAccountTokenResult', id: any, secret: string } }; + +export type RevokeServiceAccountTokenMutationVariables = Exact<{ + id: Scalars['Id']['input']; +}>; + + +export type RevokeServiceAccountTokenMutation = { __typename?: 'MutationRoot', revokeServiceAccountToken: boolean }; + export type CreateStorageMappingMutationVariables = Exact<{ catalogPrefix: Scalars['Prefix']['input']; spec: Scalars['JSON']['input']; @@ -2327,13 +2326,13 @@ 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; -<<<<<<< HEAD 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 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":"multiUse"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"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; ->>>>>>> 72f13fa7 (service accoutns) 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 ServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServiceAccounts"},"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":"serviceAccounts"},"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":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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":"createdBy"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}}]}}]}},{"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 CreateServiceAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"grants"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountGrantInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"grants"},"value":{"kind":"Variable","name":{"kind":"Name","value":"grants"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"}}]}}]}}]} as unknown as DocumentNode; +export const CreateServiceAccountTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccountToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"createServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}},{"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 RevokeServiceAccountTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeServiceAccountToken"},"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":"revokeServiceAccountToken"},"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 8498feabd1..1557987c25 100644 --- a/src/gql-types/schema.graphql +++ b/src/gql-types/schema.graphql @@ -347,10 +347,7 @@ enum CapabilityBit { DeleteGrant JournalAppend JournalRead -<<<<<<< HEAD -======= ManageServiceAccount ->>>>>>> 72f13fa7 (service accoutns) ModifyDataPlanePrivateNetworking SpecEdit ViewDataPlanePrivateNetworking @@ -1091,8 +1088,6 @@ type MutationRoot { ): RefreshTokenResult! """ -<<<<<<< HEAD -======= Create a service account homed at the specified catalog name, seeded with the given user_grants. @@ -1129,7 +1124,6 @@ type MutationRoot { ): CreateServiceAccountTokenResult! """ ->>>>>>> 72f13fa7 (service accoutns) Create a storage mapping for the given catalog prefix. This validates that the user has admin access to the catalog prefix, @@ -1160,15 +1154,6 @@ type MutationRoot { redeemInviteLink(token: UUID!): RedeemInviteLinkResult! """ -<<<<<<< HEAD - 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! -======= Remove a user_grant from a service account. The caller must manage the service account (ManageServiceAccount on its @@ -1200,7 +1185,6 @@ type MutationRoot { tokens are treated as not found. """ revokeServiceAccountToken(id: Id!): Boolean! ->>>>>>> 72f13fa7 (service accoutns) setBillingPaymentMethod(paymentMethodId: String!, tenant: String!): BillingPaymentMethodPayload! """ @@ -1545,10 +1529,7 @@ type QueryRoot { """List refresh tokens owned by the authenticated user.""" refreshTokens(after: String, first: Int): RefreshTokenInfoConnection! -<<<<<<< HEAD -======= serviceAccounts(after: String, first: Int): ServiceAccountConnection! ->>>>>>> 72f13fa7 (service accoutns) """ Returns storage mappings accessible to the current user. @@ -1624,8 +1605,6 @@ type RepublishRequested { receivedAt: DateTime! } -<<<<<<< HEAD -======= type ServiceAccount { catalogName: Name! createdAt: DateTime! @@ -1672,7 +1651,6 @@ type ServiceAccountTokenInfo { lastUsedAt: DateTime } ->>>>>>> 72f13fa7 (service accoutns) """The shape of a connector status, which matches that of an ops::Log.""" type ShardFailure { """ diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 377fd457d2..17288b847b 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -12,7 +12,6 @@ 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.`, -<<<<<<< HEAD '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`, @@ -32,19 +31,6 @@ export const AdminPage: Record = { '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.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.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.dialog.alert.tokenEncodingFailed': `An issue was encountered displaying your token. Please generate a new token.`, ->>>>>>> 72f13fa7 (service accoutns) '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.`, @@ -159,7 +145,6 @@ export const AdminPage: Record = { 'admin.tabs.users': `Account Access`, 'admin.tabs.notifications': `Notifications`, - 'admin.tabs.serviceAccounts': `Service Accounts`, 'admin.tabs.api': `CLI-API`, 'admin.tabs.billing': `Billing`, 'admin.tabs.settings': `Settings`, From 300c6f7733dec302060a4837827ec6740bc9f4e3 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 18 Jun 2026 22:49:18 -0400 Subject: [PATCH 3/9] wip --- src/components/admin/Api/index.tsx | 34 ------------------------------ src/components/admin/Tabs.tsx | 5 ----- src/context/Router/index.tsx | 11 +++++----- src/lang/en-US/AdminPage.ts | 2 +- src/pages/FlowctlAccessToken.tsx | 4 ---- 5 files changed, 7 insertions(+), 49 deletions(-) delete mode 100644 src/components/admin/Api/index.tsx diff --git a/src/components/admin/Api/index.tsx b/src/components/admin/Api/index.tsx deleted file mode 100644 index 0daafc42ea..0000000000 --- a/src/components/admin/Api/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Box, Link, Typography } from '@mui/material'; - -import { Link as RouterLink } from 'react-router-dom'; - -import { authenticatedRoutes } from 'src/app/routes'; -import AdminTabs from 'src/components/admin/Tabs'; -import usePageTitle from 'src/hooks/usePageTitle'; - -function AdminApi() { - usePageTitle({ - header: authenticatedRoutes.admin.api.title, - }); - - return ( - <> - - - - - Refresh tokens and access tokens have moved to the{' '} - - Service Accounts - {' '} - tab. - - - - ); -} - -export default AdminApi; diff --git a/src/components/admin/Tabs.tsx b/src/components/admin/Tabs.tsx index 1e80c97847..ba467873b7 100644 --- a/src/components/admin/Tabs.tsx +++ b/src/components/admin/Tabs.tsx @@ -34,11 +34,6 @@ function AdminTabs() { path: authenticatedRoutes.admin.serviceAccounts.fullPath, }); - response.push({ - labelMessageId: 'admin.tabs.api', - path: authenticatedRoutes.admin.api.fullPath, - }); - return response; }, [hasAnyAccess]); diff --git a/src/context/Router/index.tsx b/src/context/Router/index.tsx index 9ffd18a27c..8106135824 100644 --- a/src/context/Router/index.tsx +++ b/src/context/Router/index.tsx @@ -4,6 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { createBrowserRouter, createRoutesFromElements, + redirect, Route, RouterProvider, Routes, @@ -11,7 +12,6 @@ import { import { authenticatedRoutes, unauthenticatedRoutes } from 'src/app/routes'; import AccessGrants from 'src/components/admin/AccessGrants'; -import AdminApi from 'src/components/admin/Api'; import AdminBilling from 'src/components/admin/Billing'; import { ServiceAccounts } from 'src/components/admin/ServiceAccounts'; import AdminSettings from 'src/components/admin/Settings'; @@ -694,10 +694,11 @@ const router = createBrowserRouter( /> - - + loader={() => + redirect( + authenticatedRoutes.flowctl.accessToken + .fullPath + ) } /> diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 17288b847b..221a39233d 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -145,7 +145,7 @@ export const AdminPage: Record = { 'admin.tabs.users': `Account Access`, 'admin.tabs.notifications': `Notifications`, - 'admin.tabs.api': `CLI-API`, + 'admin.tabs.serviceAccounts': `Service Accounts`, 'admin.tabs.billing': `Billing`, 'admin.tabs.settings': `Settings`, diff --git a/src/pages/FlowctlAccessToken.tsx b/src/pages/FlowctlAccessToken.tsx index 06f25c7e5c..e0bd6a2733 100644 --- a/src/pages/FlowctlAccessToken.tsx +++ b/src/pages/FlowctlAccessToken.tsx @@ -41,10 +41,6 @@ function FlowctlAccessToken() { return ( - - Flowctl access token - - {accessToken ? ( )} diff --git a/src/components/admin/Api/RefreshToken/RevokeDialog.tsx b/src/components/admin/Api/RefreshToken/RevokeDialog.tsx index e52d592351..75054d5b3e 100644 --- a/src/components/admin/Api/RefreshToken/RevokeDialog.tsx +++ b/src/components/admin/Api/RefreshToken/RevokeDialog.tsx @@ -60,7 +60,7 @@ export function RevokeDialog({ open, onClose, id, detail }: Props) { fullWidth > - + Remove Personal Token @@ -68,18 +68,11 @@ export function RevokeDialog({ open, onClose, id, detail }: Props) { ) : null} - {detail ? ( - - ) : ( - - )} - - - + {detail + ? `Remove the personal token "${detail}"?` + : 'Remove this personal token?'} + This action is permanent. diff --git a/src/components/admin/Api/RefreshToken/Table.tsx b/src/components/admin/Api/RefreshToken/Table.tsx index 6e65f3708a..f96f25af77 100644 --- a/src/components/admin/Api/RefreshToken/Table.tsx +++ b/src/components/admin/Api/RefreshToken/Table.tsx @@ -58,17 +58,12 @@ function Row({ row }: RowProps) { - {intl.formatMessage( - { id: 'admin.cli_api.refreshToken.table.label.uses' }, - { count: row.uses } - )} + {`Used ${row.uses} ${row.uses === 1 ? 'time' : 'times'}`} {row.expired ? ( - - - + Expired ) : null} @@ -134,7 +129,7 @@ export function RefreshTokenTable() { }} > - + There was an error loading personal tokens. ) : null} @@ -158,10 +153,10 @@ export function RefreshTokenTable() { - + Label - + Uses @@ -185,7 +180,7 @@ export function RefreshTokenTable() { sx={{ textAlign: 'center', p: 4 }} > - + No personal tokens found. - + Create one now diff --git a/src/components/admin/Api/RefreshToken/index.tsx b/src/components/admin/Api/RefreshToken/index.tsx index f45311ed73..80959ea435 100644 --- a/src/components/admin/Api/RefreshToken/index.tsx +++ b/src/components/admin/Api/RefreshToken/index.tsx @@ -1,7 +1,5 @@ import { Box, Stack, Typography } from '@mui/material'; -import { FormattedMessage } from 'react-intl'; - import { RefreshTokenTable } from 'src/components/admin/Api/RefreshToken/Table'; export function RefreshToken() { @@ -15,11 +13,12 @@ export function RefreshToken() { fontWeight: '400', }} > - + Personal Tokens - + Personal tokens enable programmatic access to most services + including the Kafka compatible API “dekaf”. diff --git a/src/components/admin/ServiceAccounts/index.tsx b/src/components/admin/ServiceAccounts/index.tsx index fc66bc60f7..dcd348a901 100644 --- a/src/components/admin/ServiceAccounts/index.tsx +++ b/src/components/admin/ServiceAccounts/index.tsx @@ -1,7 +1,6 @@ import { Box, Stack, Typography } from '@mui/material'; import { authenticatedRoutes } from 'src/app/routes'; -import { RefreshToken } from 'src/components/admin/Api/RefreshToken'; import { ServiceAccountsTable } from 'src/components/admin/ServiceAccounts/Table'; import AdminTabs from 'src/components/admin/Tabs'; import usePageTitle from 'src/hooks/usePageTitle'; @@ -31,8 +30,6 @@ export function ServiceAccounts() {
- - ); } diff --git a/src/components/menus/UserMenu.tsx b/src/components/menus/UserMenu.tsx index 7bf6a3ccd4..c247904808 100644 --- a/src/components/menus/UserMenu.tsx +++ b/src/components/menus/UserMenu.tsx @@ -8,9 +8,11 @@ import MenuItem from '@mui/material/MenuItem'; import { useShallow } from 'zustand/react/shallow'; -import { LogOut, Mail, ProfileCircle } from 'iconoir-react'; +import { Key, LogOut, Mail, ProfileCircle } from 'iconoir-react'; import { FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { authenticatedRoutes } from 'src/app/routes'; import IconMenu from 'src/components/menus/IconMenu'; import UserAvatar from 'src/components/shared/UserAvatar'; import { supabaseClient } from 'src/context/GlobalProviders'; @@ -76,6 +78,17 @@ const UserMenu = ({ iconColor }: Props) => { + + + + + + + + { void handlers.logout(); diff --git a/src/context/Router/index.tsx b/src/context/Router/index.tsx index 8106135824..a597dd247e 100644 --- a/src/context/Router/index.tsx +++ b/src/context/Router/index.tsx @@ -40,6 +40,7 @@ import BasicLogin from 'src/pages/login/Basic'; import EnterpriseLogin from 'src/pages/login/Enterprise'; import MarketplaceCallback from 'src/pages/marketplace/Callback'; import MarketplaceVerification from 'src/pages/marketplace/Verification'; +import PersonalTokens from 'src/pages/PersonalTokens'; import { SSORequired } from 'src/pages/SSORequired'; import { isProduction } from 'src/utils/env-utils'; @@ -240,6 +241,13 @@ const router = createBrowserRouter( element={} /> + } + /> + } diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 221a39233d..adbad1bcdd 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -12,25 +12,6 @@ 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.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.table.status.expired': `Expired`, - 'admin.cli_api.refreshToken.table.noContent.header': `No refresh tokens found.`, - 'admin.cli_api.refreshToken.table.noContent.cta': `Create one now`, - 'admin.cli_api.refreshToken.dialog.header': `Create Refresh Token`, - 'admin.cli_api.refreshToken.dialog.label': `Label`, - 'admin.cli_api.refreshToken.dialog.cta.create': `Create`, - 'admin.cli_api.refreshToken.dialog.alert.copyToken': `Copy this refresh token now - you won't be able to see it again!`, - 'admin.cli_api.refreshToken.dialog.alert.tokenEncodingFailed': `An issue was encountered displaying your token. Please generate a new token.`, - 'admin.cli_api.refreshToken.revoke.header': `Remove Refresh Token`, - 'admin.cli_api.refreshToken.revoke.message': `Remove this refresh token?`, - 'admin.cli_api.refreshToken.revoke.message.named': `Remove the refresh token "{detail}"?`, - 'admin.cli_api.refreshToken.revoke.permanent': `This action is permanent.`, 'admin.billing.header': `Billing`, 'admin.billing.message.freeTier': `The free tier lets you try ${CommonMessages.productName} with up to 2 tasks and 10GB per month without entering a credit card. Usage beyond these limits automatically starts a 30 day free trial.`, diff --git a/src/lang/en-US/Navigation.ts b/src/lang/en-US/Navigation.ts index 304f759a81..f7c6472fbc 100644 --- a/src/lang/en-US/Navigation.ts +++ b/src/lang/en-US/Navigation.ts @@ -26,6 +26,7 @@ export const Navigation: Record = { 'accountMenu.ariaLabel': `Open Account Menu`, 'accountMenu.tooltip': `My Account`, 'accountMenu.emailVerified': `verified`, + 'accountMenu.personalTokens': `Personal Tokens`, 'modeSwitch.label': `Toggle Color Mode`, diff --git a/src/lang/en-US/RouteTitles.ts b/src/lang/en-US/RouteTitles.ts index f53969cca9..72ba859e0e 100644 --- a/src/lang/en-US/RouteTitles.ts +++ b/src/lang/en-US/RouteTitles.ts @@ -30,6 +30,8 @@ export const RouteTitles: Record = { 'routeTitle.materializationEdit': `Edit Materialization`, 'routeTitle.materializations': `${CommonMessages['terms.destinations']}`, 'routeTitle.registration': `Registration`, + 'routeTitle.settings': `Settings`, + 'routeTitle.settings.personalTokens': `Personal Tokens`, // The routes with custom prefix values // The some of these strings are generated in login/Basic and login/Enterprise diff --git a/src/pages/PersonalTokens.tsx b/src/pages/PersonalTokens.tsx new file mode 100644 index 0000000000..991b46351e --- /dev/null +++ b/src/pages/PersonalTokens.tsx @@ -0,0 +1,19 @@ +import { Box } from '@mui/material'; + +import { authenticatedRoutes } from 'src/app/routes'; +import { RefreshToken } from 'src/components/admin/Api/RefreshToken'; +import usePageTitle from 'src/hooks/usePageTitle'; + +function PersonalTokens() { + usePageTitle({ + header: authenticatedRoutes.settings.personalTokens.title, + }); + + return ( + + + + ); +} + +export default PersonalTokens; From a5d5b85ba42b90af411b889ee3306485512d4905 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Fri, 19 Jun 2026 12:50:42 -0400 Subject: [PATCH 7/9] filter out SAs from user list --- src/api/combinedGrantsExt.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/api/combinedGrantsExt.ts b/src/api/combinedGrantsExt.ts index c6d80f3055..c4c3e7aea1 100644 --- a/src/api/combinedGrantsExt.ts +++ b/src/api/combinedGrantsExt.ts @@ -12,6 +12,12 @@ import { } from 'src/services/supabase'; import { getCountSettings } from 'src/utils/table-utils'; +// Service accounts are backed by a synthetic auth.users row whose email always +// ends in this domain (see internal.service_accounts). They surface in +// combined_grants_ext like any human user, so the user-listing queries below +// exclude them by email suffix. +const SERVICE_ACCOUNT_EMAIL_PATTERN = '%@service_accounts.estuary.dev'; + // Used to display prefix grants in admin page const getGrants = ( pagination: any, @@ -64,7 +70,8 @@ const getGrants_Users = ( count: 'exact', } ) - .or('user_email.neq.null,user_full_name.neq.null'); + .or('user_email.neq.null,user_full_name.neq.null') + .not('user_email', 'like', SERVICE_ACCOUNT_EMAIL_PATTERN); return defaultTableFilter( query, @@ -120,6 +127,7 @@ const getUserInformationByPrefix = ( .in('object_role', evaluatedObjectRoles) .is('subject_role', null) .filter('user_email', 'not.is', null) + .not('user_email', 'like', SERVICE_ACCOUNT_EMAIL_PATTERN) .returns(); }; From 3b67d0617a3967bb1b4f38e48305aed7e8a4fdf6 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Fri, 19 Jun 2026 22:08:46 -0400 Subject: [PATCH 8/9] wip --- src/api/combinedGrantsExt.ts | 53 +- src/api/gql/serviceAccounts.ts | 67 +- src/app/routes.ts | 5 + .../admin/ServiceAccounts/AccountCard.tsx | 272 +++++++ .../admin/ServiceAccounts/ApiKeysRow.tsx | 222 ------ .../ServiceAccounts/CapabilitySelector.tsx | 48 ++ .../admin/ServiceAccounts/CatalogName.tsx | 72 ++ .../ServiceAccounts/CompactAccountCard.tsx | 88 +++ .../ServiceAccounts/CreateApiKeyDialog.tsx | 211 +++--- .../admin/ServiceAccounts/CreateDialog.tsx | 706 +++++++++++++----- .../Details/ApiKeysSection.tsx | 279 +++++++ .../ServiceAccounts/Details/GrantsSection.tsx | 257 +++++++ .../admin/ServiceAccounts/Details/index.tsx | 229 ++++++ .../admin/ServiceAccounts/EmptyState.tsx | 73 ++ .../admin/ServiceAccounts/GrantDialog.tsx | 193 +++++ .../ServiceAccounts/LifetimeSelector.tsx | 29 + src/components/admin/ServiceAccounts/List.tsx | 219 ++++++ src/components/admin/ServiceAccounts/Row.tsx | 110 --- .../ServiceAccounts/SecretRevealModal.tsx | 162 ++++ .../admin/ServiceAccounts/Table.tsx | 151 ---- .../admin/ServiceAccounts/index.tsx | 19 +- .../admin/ServiceAccounts/shared.ts | 78 ++ .../admin/ServiceAccounts/usePrefixLeaves.ts | 28 + src/context/Router/index.tsx | 15 + src/gql-types/gql.ts | 12 + src/gql-types/graphql.ts | 67 ++ src/gql-types/schema.graphql | 39 + .../useServiceAccountGrants.ts | 79 ++ src/lang/en-US/RouteTitles.ts | 1 + 29 files changed, 3006 insertions(+), 778 deletions(-) create mode 100644 src/components/admin/ServiceAccounts/AccountCard.tsx delete mode 100644 src/components/admin/ServiceAccounts/ApiKeysRow.tsx create mode 100644 src/components/admin/ServiceAccounts/CapabilitySelector.tsx create mode 100644 src/components/admin/ServiceAccounts/CatalogName.tsx create mode 100644 src/components/admin/ServiceAccounts/CompactAccountCard.tsx create mode 100644 src/components/admin/ServiceAccounts/Details/ApiKeysSection.tsx create mode 100644 src/components/admin/ServiceAccounts/Details/GrantsSection.tsx create mode 100644 src/components/admin/ServiceAccounts/Details/index.tsx create mode 100644 src/components/admin/ServiceAccounts/EmptyState.tsx create mode 100644 src/components/admin/ServiceAccounts/GrantDialog.tsx create mode 100644 src/components/admin/ServiceAccounts/LifetimeSelector.tsx create mode 100644 src/components/admin/ServiceAccounts/List.tsx delete mode 100644 src/components/admin/ServiceAccounts/Row.tsx create mode 100644 src/components/admin/ServiceAccounts/SecretRevealModal.tsx delete mode 100644 src/components/admin/ServiceAccounts/Table.tsx create mode 100644 src/components/admin/ServiceAccounts/shared.ts create mode 100644 src/components/admin/ServiceAccounts/usePrefixLeaves.ts create mode 100644 src/hooks/serviceAccounts/useServiceAccountGrants.ts diff --git a/src/api/combinedGrantsExt.ts b/src/api/combinedGrantsExt.ts index c4c3e7aea1..c23fd40524 100644 --- a/src/api/combinedGrantsExt.ts +++ b/src/api/combinedGrantsExt.ts @@ -131,4 +131,55 @@ const getUserInformationByPrefix = ( .returns(); }; -export { getGrants, getGrants_Users, getUserInformationByPrefix }; +// A service account is a synthetic user, so its access grants surface in +// combined_grants_ext as user_grants — keyed by the account's user_email (which +// is the catalog name plus this domain), with subject_role null. object_role is +// the prefix the account can act on, at `capability`. +const SERVICE_ACCOUNT_EMAIL_DOMAIN = '@service_accounts.estuary.dev'; + +export function serviceAccountEmail(catalogName: string): string { + return `${catalogName}${SERVICE_ACCOUNT_EMAIL_DOMAIN}`; +} + +export function catalogNameFromServiceAccountEmail(email: string): string { + return email.endsWith(SERVICE_ACCOUNT_EMAIL_DOMAIN) + ? email.slice(0, -SERVICE_ACCOUNT_EMAIL_DOMAIN.length) + : email; +} + +export interface ServiceAccountGrant { + id: string; + object_role: string; + capability: Capability; + updated_at: string; +} + +export interface ServiceAccountGrantRow extends ServiceAccountGrant { + user_email: string; +} + +// Grants for a single service account, used by the detail screen. +const getServiceAccountGrants = (catalogName: string) => + supabaseClient + .from(TABLES.COMBINED_GRANTS_EXT) + .select('id, object_role, capability, updated_at') + .eq('user_email', serviceAccountEmail(catalogName)) + .order('object_role', { ascending: true }) + .returns(); + +// Grants for many accounts in one round trip, used by the list grid. Callers +// group the rows by user_email (mapped back to catalog name) on the client. +const getServiceAccountGrantsByNames = (catalogNames: string[]) => + supabaseClient + .from(TABLES.COMBINED_GRANTS_EXT) + .select('id, user_email, object_role, capability, updated_at') + .in('user_email', catalogNames.map(serviceAccountEmail)) + .returns(); + +export { + getGrants, + getGrants_Users, + getServiceAccountGrants, + getServiceAccountGrantsByNames, + getUserInformationByPrefix, +}; diff --git a/src/api/gql/serviceAccounts.ts b/src/api/gql/serviceAccounts.ts index 7a1bd56e7a..ecd358c971 100644 --- a/src/api/gql/serviceAccounts.ts +++ b/src/api/gql/serviceAccounts.ts @@ -1,6 +1,6 @@ import type { ServiceAccount } from 'src/gql-types/graphql'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useMutation, useQuery } from 'urql'; @@ -65,6 +65,40 @@ export function useServiceAccounts(afterCursor?: string) { }; } +// The schema exposes service accounts only as a paginated connection — there is +// no by-name lookup yet. The detail page loads a generous page and finds the +// account client-side, which is fine for realistic account counts. A dedicated +// `serviceAccount(catalogName)` field would make this an O(1) fetch. +const SERVICE_ACCOUNT_LOOKUP_LIMIT = 250; + +export function useServiceAccount(catalogName: string | null) { + const [{ fetching, data, error }, reexecuteQuery] = useQuery({ + query: SERVICE_ACCOUNTS_QUERY, + variables: { first: SERVICE_ACCOUNT_LOOKUP_LIMIT }, + pause: !catalogName, + }); + + const serviceAccount = useMemo( + () => + catalogName + ? (data?.serviceAccounts?.edges + ?.map((edge) => edge.node) + .find((node) => node.catalogName === catalogName) ?? null) + : null, + [data, catalogName] + ); + + // Token mutations don't return the ServiceAccount type, so URQL won't + // invalidate this query automatically — callers refetch after minting or + // revoking a key. + const refetch = useCallback( + () => reexecuteQuery({ requestPolicy: 'network-only' }), + [reexecuteQuery] + ); + + return { serviceAccount, fetching, error, refetch }; +} + // A service account is homed at `catalogName` (its management anchor) and // seeded with one or more grants, each granting a capability on a prefix. const CREATE_SERVICE_ACCOUNT = graphql(` @@ -105,6 +139,29 @@ const REVOKE_SERVICE_ACCOUNT_TOKEN = graphql(` } `); +// Grants a capability on a prefix to an existing account. Re-adding a prefix +// the account already has updates its capability, so this also backs the +// "edit capability" action. +const ADD_SERVICE_ACCOUNT_GRANT = graphql(` + mutation AddServiceAccountGrant( + $catalogName: Name! + $prefix: Prefix! + $capability: Capability! + ) { + addServiceAccountGrant( + catalogName: $catalogName + prefix: $prefix + capability: $capability + ) + } +`); + +const REMOVE_SERVICE_ACCOUNT_GRANT = graphql(` + mutation RemoveServiceAccountGrant($catalogName: Name!, $prefix: Prefix!) { + removeServiceAccountGrant(catalogName: $catalogName, prefix: $prefix) + } +`); + export function useCreateServiceAccount() { return useMutation(CREATE_SERVICE_ACCOUNT); } @@ -116,3 +173,11 @@ export function useCreateServiceAccountToken() { export function useRevokeServiceAccountToken() { return useMutation(REVOKE_SERVICE_ACCOUNT_TOKEN); } + +export function useAddServiceAccountGrant() { + return useMutation(ADD_SERVICE_ACCOUNT_GRANT); +} + +export function useRemoveServiceAccountGrant() { + return useMutation(REMOVE_SERVICE_ACCOUNT_GRANT); +} diff --git a/src/app/routes.ts b/src/app/routes.ts index 34da290f95..6cfa9ca948 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -35,6 +35,11 @@ const admin = { title: 'routeTitle.admin.serviceAccounts', path: 'serviceAccounts', fullPath: '/admin/serviceAccounts', + details: { + title: 'routeTitle.admin.serviceAccounts.details', + path: 'details', + fullPath: '/admin/serviceAccounts/details', + }, }, settings: { title: 'routeTitle.admin.settings', diff --git a/src/components/admin/ServiceAccounts/AccountCard.tsx b/src/components/admin/ServiceAccounts/AccountCard.tsx new file mode 100644 index 0000000000..99bc7b1445 --- /dev/null +++ b/src/components/admin/ServiceAccounts/AccountCard.tsx @@ -0,0 +1,272 @@ +import type { ServiceAccountGrant } from 'src/api/combinedGrantsExt'; +import type { ServiceAccount } from 'src/gql-types/graphql'; +import type { SxProps, Theme } from '@mui/material'; + +import { Box, ButtonBase, Chip, Stack, Typography } from '@mui/material'; + +import { Key, Lock } from 'iconoir-react'; + +import { DateTime } from 'luxon'; + +import { + defaultBoxShadow, + defaultOutline, + defaultOutline_hovered, + diminishedTextColor, + logoColors, + semiTransparentBackground, +} from 'src/context/Theme'; + +import CatalogName from 'src/components/admin/ServiceAccounts/CatalogName'; +import { capabilityColor, monogram } from 'src/components/admin/ServiceAccounts/shared'; + +interface AccountCardProps { + serviceAccount: ServiceAccount; + grants: ServiceAccountGrant[]; + onOpen: (catalogName: string) => void; +} + +const META_LABEL_SX: SxProps = { + fontSize: 10, + letterSpacing: '0.08em', + textTransform: 'uppercase', + fontWeight: 600, + color: (theme) => diminishedTextColor[theme.palette.mode], +}; + +function lastUsedSummary(lastUsedAt: string | null | undefined): string { + if (!lastUsedAt) { + return 'Active · never used'; + } + + return `Active · used ${DateTime.fromISO(lastUsedAt).toRelative()}`; +} + +function AccountCard({ serviceAccount, grants, onOpen }: AccountCardProps) { + const grantCount = grants.length; + const hasGrants = grantCount > 0; + const keyCount = serviceAccount.tokens.length; + + const capabilities = [...new Set(grants.map((grant) => grant.capability))]; + + return ( + onOpen(serviceAccount.catalogName)} + sx={{ + 'display': 'block', + 'width': '100%', + 'textAlign': 'left', + 'p': 2, + 'borderRadius': 3, + 'background': (theme) => + hasGrants + ? semiTransparentBackground[theme.palette.mode] + : 'transparent', + 'border': (theme) => defaultOutline[theme.palette.mode], + 'borderStyle': hasGrants ? 'solid' : 'dashed', + 'opacity': hasGrants ? 1 : 0.7, + 'transition': 'transform 0.1s ease, box-shadow 0.1s ease', + '&:hover': { + border: (theme) => + defaultOutline_hovered[theme.palette.mode], + borderStyle: hasGrants ? 'solid' : 'dashed', + boxShadow: hasGrants ? defaultBoxShadow : undefined, + opacity: hasGrants ? 1 : 0.85, + transform: hasGrants ? 'translateY(-2px)' : undefined, + }, + }} + > + + {/* Identity */} + + + theme.palette.mode === 'dark' + ? 'rgba(247, 249, 252, 0.08)' + : 'rgba(11, 19, 30, 0.06)', + }} + > + {monogram(serviceAccount.catalogName)} + + + + + + + + hasGrants + ? theme.palette.success.main + : diminishedTextColor[ + theme.palette.mode + ], + }} + /> + + {hasGrants + ? lastUsedSummary(serviceAccount.lastUsedAt) + : 'No access granted'} + + + + + + theme.palette.divider, + }} + /> + + {/* Details */} + + + + Access + + + {hasGrants ? ( + + {capabilities.map((capability) => ( + + ))} + + {`${grantCount} ${grantCount === 1 ? 'prefix' : 'prefixes'}`} + + + ) : ( + + + + No access + + + )} + + + + + API keys + + + + + + + {keyCount === 0 + ? 'No keys' + : `${keyCount} ${keyCount === 1 ? 'key' : 'keys'}`} + + + + + + + Created + + + {DateTime.fromISO( + serviceAccount.createdAt + ).toLocaleString(DateTime.DATE_MED)} + + + + + + ); +} + +export default AccountCard; diff --git a/src/components/admin/ServiceAccounts/ApiKeysRow.tsx b/src/components/admin/ServiceAccounts/ApiKeysRow.tsx deleted file mode 100644 index b4966b67e4..0000000000 --- a/src/components/admin/ServiceAccounts/ApiKeysRow.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import type { - ServiceAccount, - ServiceAccountTokenInfo, -} from 'src/gql-types/graphql'; - -import { useState } from 'react'; - -import { - Box, - Button, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Typography, -} from '@mui/material'; - -import { DateTime } from 'luxon'; - -import { useRevokeServiceAccountToken } from 'src/api/gql/serviceAccounts'; -import CreateApiKeyDialog from 'src/components/admin/ServiceAccounts/CreateApiKeyDialog'; -import Error from 'src/components/shared/Error'; - -interface Props { - serviceAccount: Pick; -} - -function isExpired(expiresAt: string): boolean { - return DateTime.fromISO(expiresAt) < DateTime.now(); -} - -function ApiKeyRow({ apiKey }: { apiKey: ServiceAccountTokenInfo }) { - const [, revokeServiceAccountToken] = useRevokeServiceAccountToken(); - const [confirmOpen, setConfirmOpen] = useState(false); - const [revoking, setRevoking] = useState(false); - const [error, setError] = useState(null); - - const expired = isExpired(apiKey.expiresAt); - const label = apiKey.detail ?? 'Unnamed key'; - - const handleRevoke = async () => { - setError(null); - setRevoking(true); - - const result = await revokeServiceAccountToken({ id: apiKey.id }); - - setRevoking(false); - - if (result.error) { - setError(result.error.message); - return; - } - - setConfirmOpen(false); - }; - - return ( - - - {label} - - - - - {DateTime.fromISO(apiKey.createdAt).toLocaleString( - DateTime.DATE_MED - )} - - - - - - - {DateTime.fromISO(apiKey.expiresAt).toLocaleString( - DateTime.DATE_MED - )} - - {expired ? ( - - ) : null} - - - - - - {apiKey.lastUsedAt - ? DateTime.fromISO(apiKey.lastUsedAt).toRelative() - : 'Never'} - - - - - - - setConfirmOpen(false)} - maxWidth="xs" - fullWidth - > - Revoke API Key - - - {error ? ( - - ) : null} - - {`Revoke "${label}"? This action is permanent.`} - - {apiKey.lastUsedAt && - DateTime.fromISO(apiKey.lastUsedAt) > - DateTime.now().minus({ hours: 1 }) ? ( - - {`Processes that authenticated with this key may still have access for up to ${Math.ceil(DateTime.fromISO(apiKey.lastUsedAt).plus({ hours: 1 }).diff(DateTime.now(), 'minutes').minutes)} minutes.`} - - ) : null} - - - - - - - - - - ); -} - -function ApiKeysRow({ serviceAccount }: Props) { - return ( - - - - - API Keys - - - - - {serviceAccount.tokens.length === 0 ? ( - - No API keys. Create one to enable programmatic - authentication. - - ) : ( - - - - Label - Created - Expires - Last Used - - - - - {serviceAccount.tokens.map((token) => ( - - ))} - -
- )} -
-
-
- ); -} - -export default ApiKeysRow; diff --git a/src/components/admin/ServiceAccounts/CapabilitySelector.tsx b/src/components/admin/ServiceAccounts/CapabilitySelector.tsx new file mode 100644 index 0000000000..02e0cfb4dc --- /dev/null +++ b/src/components/admin/ServiceAccounts/CapabilitySelector.tsx @@ -0,0 +1,48 @@ +import type { Capability } from 'src/types'; + +import OutlinedToggleButton from 'src/components/shared/buttons/OutlinedToggleButton'; +import OutlinedToggleButtonGroup from 'src/components/shared/OutlinedToggleButtonGroup'; + +import { CAPABILITY_OPTIONS, capabilityColor } from 'src/components/admin/ServiceAccounts/shared'; + +interface CapabilitySelectorProps { + value: Capability; + onChange: (capability: Capability) => void; + size?: 'small' | 'medium'; + disabled?: boolean; +} + +// Segmented read / write / admin control. Each option lights up in its own +// capability color when selected. +function CapabilitySelector({ + value, + onChange, + size = 'small', + disabled, +}: CapabilitySelectorProps) { + return ( + { + if (next) { + onChange(next); + } + }} + > + {CAPABILITY_OPTIONS.map((capability) => ( + + {capability} + + ))} + + ); +} + +export default CapabilitySelector; diff --git a/src/components/admin/ServiceAccounts/CatalogName.tsx b/src/components/admin/ServiceAccounts/CatalogName.tsx new file mode 100644 index 0000000000..055b44afd4 --- /dev/null +++ b/src/components/admin/ServiceAccounts/CatalogName.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from 'react'; +import type { SxProps, Theme } from '@mui/material'; + +import { Box } from '@mui/material'; + +import { diminishedTextColor } from 'src/context/Theme'; + +import { splitCatalogName } from 'src/components/admin/ServiceAccounts/shared'; + +interface CatalogNameProps { + catalogName: string; + // Render the leaf segment in bold; the containing prefix stays muted. + emphasizeLeaf?: boolean; + sx?: SxProps; +} + +// Insert word-break opportunities after path separators so long catalog names +// wrap gracefully instead of forcing horizontal scroll. +function withBreaks(text: string): ReactNode[] { + return text.split(/(?<=[/_-])/).map((segment, index) => ( + + {segment} + + + )); +} + +// Renders a catalog name as a muted prefix plus an emphasized leaf, in +// monospace, matching how service account identities are shown in the design. +function CatalogName({ + catalogName, + emphasizeLeaf = true, + sx, +}: CatalogNameProps) { + const { prefix, leaf } = splitCatalogName(catalogName); + + return ( + + {prefix ? ( + + diminishedTextColor[theme.palette.mode], + }} + > + {withBreaks(prefix)} + + ) : null} + + + {withBreaks(leaf)} + + + ); +} + +export default CatalogName; diff --git a/src/components/admin/ServiceAccounts/CompactAccountCard.tsx b/src/components/admin/ServiceAccounts/CompactAccountCard.tsx new file mode 100644 index 0000000000..01b887b3aa --- /dev/null +++ b/src/components/admin/ServiceAccounts/CompactAccountCard.tsx @@ -0,0 +1,88 @@ +import type { ServiceAccount } from 'src/gql-types/graphql'; + +import { Box, ButtonBase, Stack } from '@mui/material'; + +import { Lock } from 'iconoir-react'; + +import CatalogName from 'src/components/admin/ServiceAccounts/CatalogName'; +import { monogram } from 'src/components/admin/ServiceAccounts/shared'; +import { defaultOutline, defaultOutline_hovered } from 'src/context/Theme'; + +interface CompactAccountCardProps { + serviceAccount: ServiceAccount; + onOpen: (catalogName: string) => void; +} + +// A reduced-detail card for accounts with no access grants. Smaller and dimmed, +// it shows only identity — these accounts can't do anything until granted +// access, so they're de-emphasized and grouped at the bottom of the list. +function CompactAccountCard({ serviceAccount, onOpen }: CompactAccountCardProps) { + return ( + onOpen(serviceAccount.catalogName)} + sx={{ + 'display': 'block', + 'width': '100%', + 'textAlign': 'left', + 'p': 1.5, + 'borderRadius': 3, + 'background': 'transparent', + 'border': (theme) => defaultOutline[theme.palette.mode], + 'borderStyle': 'dashed', + 'opacity': 0.7, + 'transition': 'opacity 0.1s ease', + '&:hover': { + border: (theme) => + defaultOutline_hovered[theme.palette.mode], + borderStyle: 'dashed', + opacity: 0.9, + }, + }} + > + + + theme.palette.mode === 'dark' + ? 'rgba(247, 249, 252, 0.08)' + : 'rgba(11, 19, 30, 0.06)', + }} + > + {monogram(serviceAccount.catalogName)} + + + + + + + + + + ); +} + +export default CompactAccountCard; diff --git a/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx index 96a2ba3a3f..e75d42cb0b 100644 --- a/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx +++ b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx @@ -1,55 +1,60 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { + Box, Button, Dialog, DialogActions, DialogContent, - DialogTitle, - FormControl, - InputLabel, - MenuItem, - Select, Stack, TextField, Typography, } from '@mui/material'; import { useCreateServiceAccountToken } from 'src/api/gql/serviceAccounts'; -import SingleLineCode from 'src/components/content/SingleLineCode'; +import LifetimeSelector from 'src/components/admin/ServiceAccounts/LifetimeSelector'; +import SecretRevealModal from 'src/components/admin/ServiceAccounts/SecretRevealModal'; +import { + DEFAULT_LIFETIME, + formatExpiryFromNow, +} from 'src/components/admin/ServiceAccounts/shared'; import AlertBox from 'src/components/shared/AlertBox'; +import DialogTitleWithClose from 'src/components/shared/Dialog/TitleWithClose'; import { hasLength } from 'src/utils/misc-utils'; -const VALIDITY_OPTIONS = [ - { label: '90 days', value: 'P90D' }, - { label: '180 days', value: 'P180D' }, - { label: '1 year', value: 'P1Y' }, -]; +const TITLE_ID = 'create-service-account-api-key'; -interface Props { +interface CreateApiKeyDialogProps { + open: boolean; catalogName: string; + onClose: () => void; + // Called once the freshly created key has been acknowledged, so the caller + // can refetch the account's keys. + onCreated?: () => void; } -function CreateApiKeyDialog({ catalogName }: Props) { - const [open, setOpen] = useState(false); +function CreateApiKeyDialog({ + open, + catalogName, + onClose, + onCreated, +}: CreateApiKeyDialogProps) { const [label, setLabel] = useState(''); - const [validFor, setValidFor] = useState('P90D'); + const [validFor, setValidFor] = useState(DEFAULT_LIFETIME); const [secret, setSecret] = useState(null); const [error, setError] = useState(null); const [{ fetching }, createServiceAccountToken] = useCreateServiceAccountToken(); - const resetForm = () => { - setLabel(''); - setValidFor('P90D'); - setSecret(null); - setError(null); - }; - - const handleClose = () => { - setOpen(false); - }; + useEffect(() => { + if (open) { + setLabel(''); + setValidFor(DEFAULT_LIFETIME); + setSecret(null); + setError(null); + } + }, [open]); const handleCreate = async () => { setError(null); @@ -75,109 +80,99 @@ function CreateApiKeyDialog({ catalogName }: Props) { setSecret(result.data.createServiceAccountToken.secret); }; + const handleRevealDone = () => { + setSecret(null); + onCreated?.(); + onClose(); + }; + return ( <> - - - - {`Create API Key for ${catalogName}`} - + + Create API key + + + {catalogName} + + {error ? ( {error} ) : null} - {secret ? ( - - - Copy this API key now — it will not be shown - again. Use it as the value of FLOW_API_KEY - in your CI/CD environment. - - - - - ) : ( - <> - setLabel(e.target.value)} - required - size="small" - fullWidth - placeholder="e.g. GitHub Actions" - /> - - - Lifetime - - - - )} + setLabel(event.target.value)} + required + size="small" + fullWidth + placeholder="CI deploy pipeline" + helperText="Helps you recognise this key later." + /> + + + + Lifetime + + + - {secret ? ( - - ) : ( - <> - - - - )} + + + + ); } diff --git a/src/components/admin/ServiceAccounts/CreateDialog.tsx b/src/components/admin/ServiceAccounts/CreateDialog.tsx index a49dd7548b..c5f71e83ff 100644 --- a/src/components/admin/ServiceAccounts/CreateDialog.tsx +++ b/src/components/admin/ServiceAccounts/CreateDialog.tsx @@ -1,228 +1,596 @@ -import { useMemo, useState } from 'react'; +import type { Capability } from 'src/types'; +import type { SxProps, Theme } from '@mui/material'; + +import { useEffect, useState } from 'react'; import { + Box, Button, Dialog, DialogActions, DialogContent, - DialogTitle, - FormControl, - InputLabel, - MenuItem, - Select, + FormControlLabel, + IconButton, Stack, + Step, + StepLabel, + Stepper, + Switch, TextField, Typography, } from '@mui/material'; -import { useIntl } from 'react-intl'; +import { NavArrowLeft, Plus, Trash } from 'iconoir-react'; -import { useLiveSpecs } from 'src/api/gql/liveSpecs'; -import { useCreateServiceAccount } from 'src/api/gql/serviceAccounts'; -import { useStorageMappings } from 'src/api/gql/storageMappings'; +import { + useCreateServiceAccount, + useCreateServiceAccountToken, +} from 'src/api/gql/serviceAccounts'; +import CapabilitySelector from 'src/components/admin/ServiceAccounts/CapabilitySelector'; +import LifetimeSelector from 'src/components/admin/ServiceAccounts/LifetimeSelector'; +import SecretRevealModal from 'src/components/admin/ServiceAccounts/SecretRevealModal'; +import { + DEFAULT_LIFETIME, + formatExpiryFromNow, +} from 'src/components/admin/ServiceAccounts/shared'; +import { usePrefixLeaves } from 'src/components/admin/ServiceAccounts/usePrefixLeaves'; import AlertBox from 'src/components/shared/AlertBox'; -import { useCouldMatchRoot } from 'src/components/shared/LeavesAutocomplete'; +import DialogTitleWithClose from 'src/components/shared/Dialog/TitleWithClose'; import { LeavesAutocomplete } from 'src/components/shared/LeavesAutocomplete/LeavesAutocomplete'; -import { useTenantStore } from 'src/stores/Tenant'; +import OutlinedToggleButton from 'src/components/shared/buttons/OutlinedToggleButton'; +import OutlinedToggleButtonGroup from 'src/components/shared/OutlinedToggleButtonGroup'; +import { codeBackground, defaultOutline } from 'src/context/Theme'; import { hasLength } from 'src/utils/misc-utils'; -// 'none' and 'write' intentionally omitted -type Capability = 'admin' | 'read'; +const TITLE_ID = 'create-service-account'; +const GUIDED_STEPS = ['Identity', 'Access', 'API key']; + +type CreateMode = 'quick' | 'guided'; + +interface GuidedGrant { + prefix: string; + capability: Capability; +} + +interface RevealState { + secret: string; + description: string; + expires: string; + account: string; +} interface CreateServiceAccountDialogProps { open: boolean; + mode: CreateMode; onClose: () => void; + onCreated?: (catalogName: string) => void; } +const FULL_NAME_SX: SxProps = { + display: 'flex', + alignItems: 'center', + gap: 1, + px: 1.5, + py: 1.25, + borderRadius: 1, + bgcolor: (theme) => codeBackground[theme.palette.mode], +}; + export function CreateServiceAccountDialog({ open, + mode, onClose, + onCreated, }: CreateServiceAccountDialogProps) { - const intl = useIntl(); + const { leaves, selectedTenant } = usePrefixLeaves(); + + const [{ fetching: creatingAccount }, createServiceAccount] = + useCreateServiceAccount(); + const [{ fetching: creatingToken }, createServiceAccountToken] = + useCreateServiceAccountToken(); + const fetching = creatingAccount || creatingToken; - const [catalogName, setCatalogName] = useState(''); - const [prefix, setPrefix] = useState(''); - const [capability, setCapability] = useState('admin'); + const [localMode, setLocalMode] = useState(mode); + const [name, setName] = useState(''); + const [location, setLocation] = useState(''); + const [grantOn, setGrantOn] = useState(true); + const [quickCapability, setQuickCapability] = useState('read'); + const [guidedGrants, setGuidedGrants] = useState([]); + const [step, setStep] = useState(1); + const [makeKey, setMakeKey] = useState(true); + const [keyDesc, setKeyDesc] = useState(''); + const [keyLife, setKeyLife] = useState(DEFAULT_LIFETIME); const [error, setError] = useState(null); - // final (startsWith) validation only applies after blur or submit; - // typing switches back to partial-only validation - const [finalEnabled, setFinalEnabled] = useState(false); - - const [{ fetching }, createServiceAccount] = useCreateServiceAccount(); - - const { storageMappings } = useStorageMappings(); - const liveSpecNames = useLiveSpecs(); - const selectedTenant = useTenantStore((state) => state.selectedTenant); - - const couldMatchRoot = useCouldMatchRoot([selectedTenant]); - - // derived during render so the trailing slash LeavesAutocomplete appends - // on blur (via onChange) is validated rather than the stale value - const partialResult = couldMatchRoot(prefix); - const prefixError = - partialResult !== true - ? partialResult - : finalEnabled && !prefix.startsWith(selectedTenant) - ? intl.formatMessage( - { id: 'leavesAutocomplete.mustStartWith.single' }, - { root: selectedTenant } - ) - : null; - - const catalogNameError = - finalEnabled && - hasLength(catalogName) && - !catalogName.startsWith(selectedTenant) - ? intl.formatMessage( - { id: 'leavesAutocomplete.mustStartWith.single' }, - { root: selectedTenant } - ) - : null; - - // build list of leaves out of live specs and storage mappings, - // scoped to the globally selected tenant - const leaves = useMemo( - () => - [ - ...liveSpecNames.map((name) => - // remove the catalog name leaving just the containing prefix - name.slice(0, name.lastIndexOf('/') + 1) - ), - ...storageMappings.map((sm) => sm.catalogPrefix), - ].filter((leaf) => leaf.startsWith(selectedTenant)), - [liveSpecNames, storageMappings, selectedTenant] - ); + const [reveal, setReveal] = useState(null); + const [createdName, setCreatedName] = useState(null); + + useEffect(() => { + if (!open) { + return; + } - const handlePrefixChange = (value: string) => { - setPrefix(value); - setFinalEnabled(false); + setLocalMode(mode); + setName(''); + setLocation(selectedTenant); + setGrantOn(true); + setQuickCapability('read'); + setGuidedGrants([{ prefix: selectedTenant, capability: 'read' }]); + setStep(1); + setMakeKey(true); + setKeyDesc(''); + setKeyLife(DEFAULT_LIFETIME); + setError(null); + setReveal(null); + setCreatedName(null); + }, [open, mode, selectedTenant]); + + const catalogName = `${location}${name}`; + const namePreview = `${location}${name || 'service-account'}`; + const identityComplete = hasLength(name) && hasLength(location); + + const updateGuidedGrant = (index: number, patch: Partial) => { + setGuidedGrants((prev) => + prev.map((grant, i) => (i === index ? { ...grant, ...patch } : grant)) + ); }; - const handlePrefixBlur = () => { - setFinalEnabled(true); + const addGuidedGrant = () => { + setGuidedGrants((prev) => [ + ...prev, + { prefix: selectedTenant, capability: 'read' }, + ]); }; - const resetForm = () => { - setCatalogName(''); - setPrefix(''); - setCapability('admin'); - setError(null); - setFinalEnabled(false); + const removeGuidedGrant = (index: number) => { + setGuidedGrants((prev) => prev.filter((_, i) => i !== index)); + }; + + const finishCreated = (createdCatalogName: string) => { + onClose(); + onCreated?.(createdCatalogName); }; - const handleClose = () => { + const handleRevealDone = () => { + const name_ = createdName; + setReveal(null); + setCreatedName(null); onClose(); - resetForm(); + if (name_) { + onCreated?.(name_); + } }; - const handleCreate = async () => { + const handleSubmit = async () => { setError(null); - if (!hasLength(catalogName) || !hasLength(prefix)) { + if (!identityComplete) { return; } - if ( - !catalogName.startsWith(selectedTenant) || - !prefix.startsWith(selectedTenant) - ) { - setFinalEnabled(true); - return; - } + const grants = + localMode === 'quick' + ? grantOn + ? [{ prefix: location, capability: quickCapability }] + : [] + : guidedGrants.filter((grant) => hasLength(grant.prefix)); - const result = await createServiceAccount({ - catalogName, - grants: [{ prefix, capability }], - }); + const result = await createServiceAccount({ catalogName, grants }); if (result.error) { setError(result.error.message); return; } - handleClose(); + if (localMode === 'guided' && makeKey) { + const tokenResult = await createServiceAccountToken({ + catalogName, + detail: keyDesc || 'Default key', + validFor: keyLife, + }); + + if ( + tokenResult.error || + !tokenResult.data?.createServiceAccountToken + ) { + // The account exists; only the key failed. Surface it but move + // on to the account so the user can retry the key there. + setError( + tokenResult.error?.message ?? + 'The account was created, but its API key could not be generated.' + ); + finishCreated(catalogName); + return; + } + + setCreatedName(catalogName); + setReveal({ + secret: tokenResult.data.createServiceAccountToken.secret, + description: keyDesc || 'API key', + expires: formatExpiryFromNow(keyLife), + account: catalogName, + }); + return; + } + + finishCreated(catalogName); }; + const nameField = ( + + setName( + event.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase() + ) + } + size="small" + fullWidth + required + placeholder="banana-bot" + helperText="Lowercase letters, numbers and dashes. Must be unique." + /> + ); + + const locationField = ( + + ); + + const fullNamePreview = ( + + + Full name + + + {namePreview} + + + ); + return ( - - Create Service Account - - - - - Create a non-login identity homed at a catalog name and - granted access to a prefix. The service account will be - able to authenticate with API keys. + <> + + + Create service account + + A non-login identity for programmatic access. + - {error ? ( - - {error} - - ) : null} - - setCatalogName(e.target.value)} - onBlur={() => setFinalEnabled(true)} - required - size="small" - fullWidth - placeholder={`${selectedTenant}ci-deploy-bot`} - error={Boolean(catalogNameError)} - helperText={ - catalogNameError ?? - 'The catalog name that anchors this account.' - } - /> - - - - - Capability - - - - - - - - + + ) : null} + + {step === 3 ? ( + <> + + + setMakeKey( + event.target + .checked + ) + } + /> + } + label="Create an API key now" + /> + + The secret is shown once on the + next screen. You can always + create keys later. + + + + {makeKey ? ( + <> + + setKeyDesc( + event.target.value + ) + } + size="small" + fullWidth + placeholder="CI deploy pipeline" + helperText="Helps you recognise this key later." + /> + + + Lifetime + + + + + ) : null} + + ) : null} + + )} + + + + - Create - - - + {localMode === 'guided' && step > 1 ? ( + + ) : ( + + )} + + {localMode === 'guided' && step < 3 ? ( + + ) : ( + + )} + + + + + ); } diff --git a/src/components/admin/ServiceAccounts/Details/ApiKeysSection.tsx b/src/components/admin/ServiceAccounts/Details/ApiKeysSection.tsx new file mode 100644 index 0000000000..679e5bb8de --- /dev/null +++ b/src/components/admin/ServiceAccounts/Details/ApiKeysSection.tsx @@ -0,0 +1,279 @@ +import type { ServiceAccountTokenInfo } from 'src/gql-types/graphql'; + +import { useState } from 'react'; + +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + IconButton, + Stack, + Typography, +} from '@mui/material'; + +import { Key, Plus, Trash } from 'iconoir-react'; + +import { DateTime } from 'luxon'; + +import { useRevokeServiceAccountToken } from 'src/api/gql/serviceAccounts'; +import AlertBox from 'src/components/shared/AlertBox'; + +interface ApiKeysSectionProps { + tokens: ServiceAccountTokenInfo[]; + onCreateKey: () => void; + onChanged: () => void; +} + +function isExpired(expiresAt: string): boolean { + return DateTime.fromISO(expiresAt) < DateTime.now(); +} + +// Minutes a recently-used key may still be honored after revocation. +function recentlyUsedGraceMinutes( + lastUsedAt: string | null | undefined +): number | null { + if (!lastUsedAt) { + return null; + } + + const usedRecently = + DateTime.fromISO(lastUsedAt) > DateTime.now().minus({ hours: 1 }); + + if (!usedRecently) { + return null; + } + + return Math.ceil( + DateTime.fromISO(lastUsedAt) + .plus({ hours: 1 }) + .diff(DateTime.now(), 'minutes').minutes + ); +} + +function ApiKeysSection({ + tokens, + onCreateKey, + onChanged, +}: ApiKeysSectionProps) { + const [revokeTarget, setRevokeTarget] = + useState(null); + const [revokeError, setRevokeError] = useState(null); + + const [{ fetching: revoking }, revokeServiceAccountToken] = + useRevokeServiceAccountToken(); + + const handleRevoke = async () => { + if (!revokeTarget) { + return; + } + + setRevokeError(null); + + const result = await revokeServiceAccountToken({ + id: revokeTarget.id, + }); + + if (result.error) { + setRevokeError(result.error.message); + return; + } + + setRevokeTarget(null); + onChanged(); + }; + + const graceMinutes = recentlyUsedGraceMinutes(revokeTarget?.lastUsedAt); + const revokeLabel = revokeTarget?.detail ?? 'Unnamed key'; + + return ( + + + + API keys + + Secrets are shown once at creation. Rotate or revoke them + anytime. + + + + + + {tokens.length === 0 ? ( + + + + No API keys yet. Create one to let this account + authenticate. + + + ) : ( + tokens.map((token) => { + const expired = isExpired(token.expiresAt); + + return ( + + `1px solid ${theme.palette.divider}`, + }} + > + + + + + + + {token.detail ?? 'Unnamed key'} + + + {`Created ${DateTime.fromISO(token.createdAt).toLocaleString(DateTime.DATE_MED)}`} + + + + + + + {`Expires ${DateTime.fromISO(token.expiresAt).toLocaleString(DateTime.DATE_MED)}`} + + {expired ? ( + + ) : null} + + + {token.lastUsedAt + ? `Last used ${DateTime.fromISO(token.lastUsedAt).toRelative()}` + : 'Never used'} + + + + { + setRevokeError(null); + setRevokeTarget(token); + }} + > + + + + ); + }) + )} + + setRevokeTarget(null)} + maxWidth="xs" + fullWidth + > + + + + Revoke API key? + + + “ + + {revokeLabel} + + ” will stop working immediately and can’t be + restored. Any integration using it will lose access. + + {graceMinutes ? ( + + {`Processes that authenticated with this key may still have access for up to ${graceMinutes} minutes.`} + + ) : null} + {revokeError ? ( + + {revokeError} + + ) : null} + + + + + + + + + ); +} + +export default ApiKeysSection; diff --git a/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx b/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx new file mode 100644 index 0000000000..13155080ec --- /dev/null +++ b/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx @@ -0,0 +1,257 @@ +import type { ServiceAccountGrant } from 'src/api/combinedGrantsExt'; +import type { Capability } from 'src/types'; + +import { useState } from 'react'; + +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + IconButton, + Stack, + Typography, +} from '@mui/material'; + +import { EditPencil, Folder, Lock, Plus, Trash } from 'iconoir-react'; + +import { useRemoveServiceAccountGrant } from 'src/api/gql/serviceAccounts'; +import GrantDialog from 'src/components/admin/ServiceAccounts/GrantDialog'; +import { capabilityColor } from 'src/components/admin/ServiceAccounts/shared'; +import { usePrefixLeaves } from 'src/components/admin/ServiceAccounts/usePrefixLeaves'; +import AlertBox from 'src/components/shared/AlertBox'; + +interface GrantsSectionProps { + catalogName: string; + grants: ServiceAccountGrant[]; + onChanged: () => void; +} + +interface GrantDialogState { + mode: 'add' | 'edit'; + prefix?: string; + capability?: Capability; +} + +function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { + const { leaves } = usePrefixLeaves(); + + const [dialog, setDialog] = useState(null); + const [removeTarget, setRemoveTarget] = + useState(null); + const [removeError, setRemoveError] = useState(null); + + const [{ fetching: removing }, removeServiceAccountGrant] = + useRemoveServiceAccountGrant(); + + const handleRemove = async () => { + if (!removeTarget) { + return; + } + + setRemoveError(null); + + const result = await removeServiceAccountGrant({ + catalogName, + prefix: removeTarget.object_role, + }); + + if (result.error) { + setRemoveError(result.error.message); + return; + } + + setRemoveTarget(null); + onChanged(); + }; + + return ( + + + + Access grants + + Catalog prefixes this account can act on, and at what + capability. + + + + + + {grants.length === 0 ? ( + + + + No access grants yet — this account can’t read or write + any data until you add one. + + + ) : ( + grants.map((grant) => ( + + `1px solid ${theme.palette.divider}`, + }} + > + + + + + {grant.object_role} + + + + setDialog({ + mode: 'edit', + prefix: grant.object_role, + capability: grant.capability, + }) + } + > + + + { + setRemoveError(null); + setRemoveTarget(grant); + }} + > + + + + )) + )} + + setDialog(null)} + onSaved={onChanged} + /> + + setRemoveTarget(null)} + maxWidth="xs" + fullWidth + > + + + theme.palette.error.alpha_12, + color: 'error.main', + }} + > + + + + + Remove access grant? + + + This account will immediately lose access to{' '} + + {removeTarget?.object_role} + + . Existing API keys will stop working for this + prefix. + + {removeError ? ( + + {removeError} + + ) : null} + + + + + + + + + + ); +} + +export default GrantsSection; diff --git a/src/components/admin/ServiceAccounts/Details/index.tsx b/src/components/admin/ServiceAccounts/Details/index.tsx new file mode 100644 index 0000000000..424ab6bc63 --- /dev/null +++ b/src/components/admin/ServiceAccounts/Details/index.tsx @@ -0,0 +1,229 @@ +import type { ReactNode } from 'react'; + +import { useState } from 'react'; + +import { Box, Button, Stack, Typography } from '@mui/material'; + +import { NavArrowLeft, Plus } from 'iconoir-react'; + +import { DateTime } from 'luxon'; + +import { useNavigate } from 'react-router-dom'; + +import { useServiceAccount } from 'src/api/gql/serviceAccounts'; +import { authenticatedRoutes } from 'src/app/routes'; +import CatalogName from 'src/components/admin/ServiceAccounts/CatalogName'; +import CreateApiKeyDialog from 'src/components/admin/ServiceAccounts/CreateApiKeyDialog'; +import ApiKeysSection from 'src/components/admin/ServiceAccounts/Details/ApiKeysSection'; +import GrantsSection from 'src/components/admin/ServiceAccounts/Details/GrantsSection'; +import { + monogram, + splitCatalogName, +} from 'src/components/admin/ServiceAccounts/shared'; +import AdminTabs from 'src/components/admin/Tabs'; +import { logoColors } from 'src/context/Theme'; +import useGlobalSearchParams, { + GlobalSearchParams, +} from 'src/hooks/searchParams/useGlobalSearchParams'; +import { useServiceAccountGrants } from 'src/hooks/serviceAccounts/useServiceAccountGrants'; +import usePageTitle from 'src/hooks/usePageTitle'; + +const META_LABEL_SX = { + fontSize: 10, + letterSpacing: '0.08em', + textTransform: 'uppercase', + fontWeight: 600, + color: 'text.secondary', +} as const; + +function MetaItem({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} + +function ServiceAccountDetails() { + usePageTitle({ + header: authenticatedRoutes.admin.serviceAccounts.details.title, + }); + + const navigate = useNavigate(); + const catalogName = useGlobalSearchParams(GlobalSearchParams.CATALOG_NAME); + + const { serviceAccount, fetching, refetch } = + useServiceAccount(catalogName); + const { grants, mutate: mutateGrants } = + useServiceAccountGrants(catalogName); + + const [createKeyOpen, setCreateKeyOpen] = useState(false); + + const goBack = () => + navigate(authenticatedRoutes.admin.serviceAccounts.fullPath); + + const backButton = ( + + ); + + let body: ReactNode; + + if (!catalogName) { + body = ( + + No service account selected. + + ); + } else if (fetching && !serviceAccount) { + body = Loading…; + } else if (!serviceAccount) { + body = ( + + {`Service account “${catalogName}” was not found.`} + + ); + } else { + const { prefix } = splitCatalogName(serviceAccount.catalogName); + + body = ( + <> + + + {monogram(serviceAccount.catalogName)} + + + + + + + + Active + + + + + + + + + + + {prefix} + + + + {DateTime.fromISO( + serviceAccount.createdAt + ).toLocaleString(DateTime.DATE_MED)} + + + {serviceAccount.lastUsedAt + ? DateTime.fromISO( + serviceAccount.lastUsedAt + ).toRelative() + : 'Never'} + + + {serviceAccount.tokens.length === 0 + ? 'None' + : String(serviceAccount.tokens.length)} + + + + + + + + setCreateKeyOpen(true)} + onChanged={refetch} + /> + + setCreateKeyOpen(false)} + onCreated={refetch} + /> + + ); + } + + return ( + <> + + + + + {backButton} + {body} + + + + ); +} + +export default ServiceAccountDetails; diff --git a/src/components/admin/ServiceAccounts/EmptyState.tsx b/src/components/admin/ServiceAccounts/EmptyState.tsx new file mode 100644 index 0000000000..04b5c9987b --- /dev/null +++ b/src/components/admin/ServiceAccounts/EmptyState.tsx @@ -0,0 +1,73 @@ +import { Box, Button, Link, Stack, Typography } from '@mui/material'; + +import { Developer, Plus } from 'iconoir-react'; + +import { defaultOutline, logoColors } from 'src/context/Theme'; + +interface EmptyStateProps { + onQuickCreate: () => void; + onGuidedCreate: () => void; +} + +function EmptyState({ onQuickCreate, onGuidedCreate }: EmptyStateProps) { + return ( + + defaultOutline[theme.palette.mode], + background: `linear-gradient(150deg, ${logoColors.purple}2e, ${logoColors.teal}24)`, + color: 'primary.main', + }} + > + + + + + No service accounts yet + + + + Create a non-login identity to give pipelines, agents and + integrations scoped, programmatic access to your catalog — with + API keys you can rotate and revoke anytime. + + + + + + Use guided setup instead → + + + ); +} + +export default EmptyState; diff --git a/src/components/admin/ServiceAccounts/GrantDialog.tsx b/src/components/admin/ServiceAccounts/GrantDialog.tsx new file mode 100644 index 0000000000..7eb1a916a4 --- /dev/null +++ b/src/components/admin/ServiceAccounts/GrantDialog.tsx @@ -0,0 +1,193 @@ +import type { Capability } from 'src/types'; + +import { useEffect, useState } from 'react'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + Stack, + Typography, +} from '@mui/material'; + +import { Lock } from 'iconoir-react'; + +import { useAddServiceAccountGrant } from 'src/api/gql/serviceAccounts'; +import CapabilitySelector from 'src/components/admin/ServiceAccounts/CapabilitySelector'; +import AlertBox from 'src/components/shared/AlertBox'; +import DialogTitleWithClose from 'src/components/shared/Dialog/TitleWithClose'; +import { LeavesAutocomplete } from 'src/components/shared/LeavesAutocomplete/LeavesAutocomplete'; +import { codeBackground } from 'src/context/Theme'; +import { hasLength } from 'src/utils/misc-utils'; + +const TITLE_ID = 'service-account-grant-dialog'; + +interface GrantDialogProps { + open: boolean; + mode: 'add' | 'edit'; + catalogName: string; + leaves: string[]; + // For edit mode: the grant being changed. + initialPrefix?: string; + initialCapability?: Capability; + onClose: () => void; + onSaved: () => void; +} + +// Adds a grant, or changes the capability of an existing one. The prefix is +// fixed once a grant exists, so edit mode locks it and only re-adds it with a +// new capability (addServiceAccountGrant upserts). +function GrantDialog({ + open, + mode, + catalogName, + leaves, + initialPrefix, + initialCapability, + onClose, + onSaved, +}: GrantDialogProps) { + const [prefix, setPrefix] = useState(''); + const [capability, setCapability] = useState('read'); + const [error, setError] = useState(null); + + const [{ fetching }, addServiceAccountGrant] = useAddServiceAccountGrant(); + + useEffect(() => { + if (open) { + setPrefix(mode === 'edit' ? (initialPrefix ?? '') : ''); + setCapability(initialCapability ?? 'read'); + setError(null); + } + }, [open, mode, initialPrefix, initialCapability]); + + const handleSave = async () => { + setError(null); + + if (!hasLength(prefix)) { + return; + } + + const result = await addServiceAccountGrant({ + catalogName, + prefix, + capability, + }); + + if (result.error) { + setError(result.error.message); + return; + } + + onSaved(); + onClose(); + }; + + return ( + + + {mode === 'add' ? 'Add access grant' : 'Edit capability'} + + + + + {error ? ( + + {error} + + ) : null} + + {mode === 'add' ? ( + + ) : ( + + + Catalog prefix + + + codeBackground[theme.palette.mode], + }} + > + + + {prefix} + + + + )} + + + + Capability + + + + + + + + + + + + ); +} + +export default GrantDialog; diff --git a/src/components/admin/ServiceAccounts/LifetimeSelector.tsx b/src/components/admin/ServiceAccounts/LifetimeSelector.tsx new file mode 100644 index 0000000000..8b374ed464 --- /dev/null +++ b/src/components/admin/ServiceAccounts/LifetimeSelector.tsx @@ -0,0 +1,29 @@ +import { Box, Button } from '@mui/material'; + +import { LIFETIME_OPTIONS } from 'src/components/admin/ServiceAccounts/shared'; + +interface LifetimeSelectorProps { + value: string; + onChange: (value: string) => void; +} + +// Row of selectable lifetime pills for an API key's `validFor`. +function LifetimeSelector({ value, onChange }: LifetimeSelectorProps) { + return ( + + {LIFETIME_OPTIONS.map((option) => ( + + ))} + + ); +} + +export default LifetimeSelector; diff --git a/src/components/admin/ServiceAccounts/List.tsx b/src/components/admin/ServiceAccounts/List.tsx new file mode 100644 index 0000000000..65a8655dc7 --- /dev/null +++ b/src/components/admin/ServiceAccounts/List.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react'; + +import { + Box, + Button, + IconButton, + Stack, + Typography, +} from '@mui/material'; + +import { NavArrowLeft, NavArrowRight, Plus } from 'iconoir-react'; + +import { useNavigate } from 'react-router-dom'; + +import { useServiceAccounts } from 'src/api/gql/serviceAccounts'; +import { authenticatedRoutes } from 'src/app/routes'; +import AccountCard from 'src/components/admin/ServiceAccounts/AccountCard'; +import CompactAccountCard from 'src/components/admin/ServiceAccounts/CompactAccountCard'; +import { CreateServiceAccountDialog } from 'src/components/admin/ServiceAccounts/CreateDialog'; +import EmptyState from 'src/components/admin/ServiceAccounts/EmptyState'; +import AlertBox from 'src/components/shared/AlertBox'; +import { GlobalSearchParams } from 'src/hooks/searchParams/useGlobalSearchParams'; +import { useServiceAccountGrantsByNames } from 'src/hooks/serviceAccounts/useServiceAccountGrants'; +import { useCursorPagination } from 'src/hooks/useCursorPagination'; + +type CreateMode = 'quick' | 'guided'; + +export function ServiceAccountsList() { + const navigate = useNavigate(); + + const { currentPage, cursor, onPageChange } = useCursorPagination(); + const { serviceAccounts, fetching, error, pageInfo, pageSize } = + useServiceAccounts(cursor); + + const { grantsByName, fetching: grantsFetching } = + useServiceAccountGrantsByNames( + serviceAccounts.map((account) => account.catalogName) + ); + + const [createOpen, setCreateOpen] = useState(false); + const [createMode, setCreateMode] = useState('quick'); + + const openCreate = (mode: CreateMode) => { + setCreateMode(mode); + setCreateOpen(true); + }; + + const openDetail = (catalogName: string) => { + navigate( + `${authenticatedRoutes.admin.serviceAccounts.details.fullPath}?${GlobalSearchParams.CATALOG_NAME}=${encodeURIComponent(catalogName)}` + ); + }; + + const hasAccounts = serviceAccounts.length > 0; + const from = currentPage * pageSize + 1; + const to = from + serviceAccounts.length - 1; + + const hasGrants = (catalogName: string) => + (grantsByName[catalogName]?.length ?? 0) > 0; + + // Accounts without any grants are de-emphasized and grouped at the bottom. + // Until grants finish loading we can't tell them apart, so keep everything + // in the main grid to avoid a flash of compact cards. + const grantedAccounts = grantsFetching + ? serviceAccounts + : serviceAccounts.filter((account) => hasGrants(account.catalogName)); + const noAccessAccounts = grantsFetching + ? [] + : serviceAccounts.filter((account) => !hasGrants(account.catalogName)); + + return ( + + + + + Service accounts + + + Service accounts provide non-login identities for CI/CD + pipelines, AI agents, and other programmatic + integrations — including the Kafka-compatible API + “dekaf”. + + + + {hasAccounts ? ( + + ) : null} + + + setCreateOpen(false)} + onCreated={openDetail} + /> + + {error ? ( + + {error.message} + + ) : null} + + {fetching && !hasAccounts ? ( + + Loading… + + ) : !hasAccounts ? ( + openCreate('quick')} + onGuidedCreate={() => openCreate('guided')} + /> + ) : ( + + {grantedAccounts.length > 0 ? ( + + {grantedAccounts.map((account) => ( + + ))} + + ) : null} + + {noAccessAccounts.length > 0 ? ( + + + No access granted + + + {noAccessAccounts.map((account) => ( + + ))} + + + ) : null} + + )} + + {pageInfo && hasAccounts ? ( + + + {`${from}–${to}`} + + + onPageChange(null, currentPage - 1, pageInfo.endCursor) + } + > + + + + onPageChange(null, currentPage + 1, pageInfo.endCursor) + } + > + + + + ) : null} + + ); +} diff --git a/src/components/admin/ServiceAccounts/Row.tsx b/src/components/admin/ServiceAccounts/Row.tsx deleted file mode 100644 index d8bbd97205..0000000000 --- a/src/components/admin/ServiceAccounts/Row.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { ServiceAccount } from 'src/gql-types/graphql'; - -import { useState } from 'react'; - -import { - Chip, - IconButton, - TableCell, - TableRow, - Tooltip, - Typography, - useTheme, -} from '@mui/material'; - -import { NavArrowDown, NavArrowRight } from 'iconoir-react'; - -import { DateTime } from 'luxon'; - -import ApiKeysRow from 'src/components/admin/ServiceAccounts/ApiKeysRow'; -import { getEntityTableRowSx } from 'src/context/Theme'; - -interface ServiceAccountRowProps { - serviceAccount: ServiceAccount; -} - -function ServiceAccountRow({ serviceAccount: sa }: ServiceAccountRowProps) { - const theme = useTheme(); - - const [expanded, setExpanded] = useState(false); - - const tokenCount = sa.tokens.length; - - return ( - <> - - - setExpanded((prev) => !prev)} - aria-label={expanded ? 'Collapse' : 'Expand'} - > - {expanded ? ( - - ) : ( - - )} - - - - - - {sa.catalogName - .split(/(?<=[/_-])/) - .map((segment: string, i: number) => ( - - {segment} - - - ))} - - - - - - {DateTime.fromISO(sa.createdAt).toLocaleString( - DateTime.DATE_MED - )} - - - - - - {sa.lastUsedAt - ? DateTime.fromISO(sa.lastUsedAt).toRelative() - : 'Never'} - - - - - - 0 ? 'info' : 'default'} - /> - - - - - {expanded ? : null} - - ); -} - -export default ServiceAccountRow; diff --git a/src/components/admin/ServiceAccounts/SecretRevealModal.tsx b/src/components/admin/ServiceAccounts/SecretRevealModal.tsx new file mode 100644 index 0000000000..5f5eaf8aca --- /dev/null +++ b/src/components/admin/ServiceAccounts/SecretRevealModal.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react'; + +import { + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + FormControlLabel, + Stack, + Typography, +} from '@mui/material'; + +import { CheckCircle } from 'iconoir-react'; + +import SingleLineCode from 'src/components/content/SingleLineCode'; +import AlertBox from 'src/components/shared/AlertBox'; + +interface SecretRevealModalProps { + open: boolean; + secret: string; + description: string; + // Pre-formatted expiry, e.g. "Sep 17, 2026" or "1 year". + expires: string; + // The owning account's catalog name. + account: string; + onDone: () => void; +} + +const META_LABEL_SX = { + fontSize: 10, + letterSpacing: '0.08em', + textTransform: 'uppercase', + fontWeight: 600, + color: 'text.secondary', +} as const; + +// Shows a freshly minted API key exactly once. The user must acknowledge they +// stored it before they can dismiss the dialog (no close affordance otherwise). +function SecretRevealModal({ + open, + secret, + description, + expires, + account, + onDone, +}: SecretRevealModalProps) { + const [acknowledged, setAcknowledged] = useState(false); + + useEffect(() => { + if (open) { + setAcknowledged(false); + } + }, [open]); + + return ( + + + + + + theme.palette.success.alpha_12, + color: 'success.main', + }} + > + + + + + API key created + + + {description} + + + + + + + This secret will not be shown again. Store it + somewhere safe before closing this dialog. Use it as + the value of FLOW_API_KEY in your CI/CD environment. + + + + + + + + + + + Expires + + {expires} + + + + Account + + + {account} + + + + + + `1px solid ${theme.palette.divider}`, + }} + > + + setAcknowledged(event.target.checked) + } + /> + } + label="I’ve copied my API key and stored it securely" + /> + + + + + + + + + ); +} + +export default SecretRevealModal; diff --git a/src/components/admin/ServiceAccounts/Table.tsx b/src/components/admin/ServiceAccounts/Table.tsx deleted file mode 100644 index 89ed60742f..0000000000 --- a/src/components/admin/ServiceAccounts/Table.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useState } from 'react'; - -import { - Box, - Button, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableFooter, - TableHead, - TablePagination, - TableRow, - Typography, -} from '@mui/material'; - -import { FormattedMessage } from 'react-intl'; - -import { useServiceAccounts } from 'src/api/gql/serviceAccounts'; -import { CreateServiceAccountDialog } from 'src/components/admin/ServiceAccounts/CreateDialog'; -import ServiceAccountRow from 'src/components/admin/ServiceAccounts/Row'; -import { useCursorPagination } from 'src/hooks/useCursorPagination'; - -// expand toggle + Name + Created + Last Used + API Keys -const COLUMN_COUNT = 5; - -export function ServiceAccountsTable() { - const { currentPage, cursor, onPageChange } = useCursorPagination(); - const { serviceAccounts, fetching, error, pageInfo, pageSize } = - useServiceAccounts(cursor); - - const handlePageChange = (_event: any, page: number) => { - onPageChange(_event, page, pageInfo?.endCursor); - }; - - const [createOpen, setCreateOpen] = useState(false); - - return ( - - - - - - setCreateOpen(false)} - /> - - {error ? ( - - {error.message} - - ) : null} - - - - - - - Name - Created - Last Used - API Keys - - - - - {fetching && serviceAccounts.length === 0 ? ( - - - - - - ) : serviceAccounts.length === 0 ? ( - - - - No service accounts found. - - setCreateOpen(true)} - sx={{ - 'cursor': 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }} - > - Create one now - - - - ) : ( - serviceAccounts.map((sa) => ( - - )) - )} - - - {pageInfo && serviceAccounts.length > 0 ? ( - - - { - const to = - from + serviceAccounts.length - 1; - return `${from}–${to}`; - }} - slotProps={{ - actions: { - previousButton: { - disabled: - !pageInfo.hasPreviousPage, - }, - nextButton: { - disabled: !pageInfo.hasNextPage, - }, - }, - }} - /> - - - ) : null} -
-
-
- ); -} diff --git a/src/components/admin/ServiceAccounts/index.tsx b/src/components/admin/ServiceAccounts/index.tsx index dcd348a901..be5ea246ff 100644 --- a/src/components/admin/ServiceAccounts/index.tsx +++ b/src/components/admin/ServiceAccounts/index.tsx @@ -1,7 +1,7 @@ -import { Box, Stack, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import { authenticatedRoutes } from 'src/app/routes'; -import { ServiceAccountsTable } from 'src/components/admin/ServiceAccounts/Table'; +import { ServiceAccountsList } from 'src/components/admin/ServiceAccounts/List'; import AdminTabs from 'src/components/admin/Tabs'; import usePageTitle from 'src/hooks/usePageTitle'; @@ -15,20 +15,7 @@ export function ServiceAccounts() { - - - Service Accounts - - - - Service accounts provide non-login identities for CI/CD - pipelines, AI agents, and other programmatic - integrations, including the Kafka compatible API - “dekaf”. - - - - + ); diff --git a/src/components/admin/ServiceAccounts/shared.ts b/src/components/admin/ServiceAccounts/shared.ts new file mode 100644 index 0000000000..6d534ea687 --- /dev/null +++ b/src/components/admin/ServiceAccounts/shared.ts @@ -0,0 +1,78 @@ +import type { Capability } from 'src/types'; + +import { DateTime, Duration } from 'luxon'; + +// A color valid for both and . +export type CapabilityColor = 'info' | 'primary' | 'warning'; + +// How each capability reads. read is the gentlest (info), write is the working +// default (primary), admin is the most privileged (warning). +const CAPABILITY_COLOR: Record = { + read: 'info', + write: 'primary', + admin: 'warning', +}; + +export function capabilityColor(capability: Capability): CapabilityColor { + return CAPABILITY_COLOR[capability] ?? 'info'; +} + +// Capabilities offered when granting access, ordered least- to most-privileged. +export const CAPABILITY_OPTIONS: Capability[] = ['read', 'write', 'admin']; + +// API key lifetimes. Values are ISO-8601 durations passed to +// createServiceAccountToken's required `validFor`. (The design's "No expiry" +// is omitted because the field is required.) +export interface LifetimeOption { + label: string; + value: string; +} + +export const LIFETIME_OPTIONS: LifetimeOption[] = [ + { label: '30 days', value: 'P30D' }, + { label: '60 days', value: 'P60D' }, + { label: '90 days', value: 'P90D' }, + { label: '1 year', value: 'P1Y' }, +]; + +export const DEFAULT_LIFETIME = 'P90D'; + +// The API returns only the secret on creation, not an expiry. Derive a +// human-readable expiry date from the ISO-8601 `validFor` for the reveal. +export function formatExpiryFromNow(validFor: string): string { + const duration = Duration.fromISO(validFor); + + if (!duration.isValid) { + return validFor; + } + + return DateTime.now().plus(duration).toLocaleString(DateTime.DATE_MED); +} + +// Split a catalog name into its containing prefix (including the trailing +// slash) and its leaf segment, e.g. "acmeCo/staging/ci-bot" -> +// { prefix: "acmeCo/staging/", leaf: "ci-bot" }. +export function splitCatalogName(catalogName: string): { + prefix: string; + leaf: string; +} { + const trimmed = catalogName.replace(/\/$/, ''); + const lastSlash = trimmed.lastIndexOf('/'); + + if (lastSlash === -1) { + return { prefix: '', leaf: trimmed }; + } + + return { + prefix: trimmed.slice(0, lastSlash + 1), + leaf: trimmed.slice(lastSlash + 1), + }; +} + +// Two-letter monogram for an account avatar, derived from its leaf name. +export function monogram(catalogName: string): string { + const { leaf } = splitCatalogName(catalogName); + const alphanumeric = leaf.replace(/[^a-z0-9]/gi, ''); + + return (alphanumeric.slice(0, 2) || 'SA').toUpperCase(); +} diff --git a/src/components/admin/ServiceAccounts/usePrefixLeaves.ts b/src/components/admin/ServiceAccounts/usePrefixLeaves.ts new file mode 100644 index 0000000000..b15a5a26ad --- /dev/null +++ b/src/components/admin/ServiceAccounts/usePrefixLeaves.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; + +import { useLiveSpecs } from 'src/api/gql/liveSpecs'; +import { useStorageMappings } from 'src/api/gql/storageMappings'; +import { useTenantStore } from 'src/stores/Tenant'; + +// Catalog prefixes a service account can be homed at or granted access to, +// derived from the tenant's live specs and storage mappings and scoped to the +// globally selected tenant. Shared by the create dialog and the grant dialog. +export function usePrefixLeaves() { + const { storageMappings } = useStorageMappings(); + const liveSpecNames = useLiveSpecs(); + const selectedTenant = useTenantStore((state) => state.selectedTenant); + + const leaves = useMemo( + () => + [ + ...liveSpecNames.map((name) => + // strip the catalog name, keeping the containing prefix + name.slice(0, name.lastIndexOf('/') + 1) + ), + ...storageMappings.map((sm) => sm.catalogPrefix), + ].filter((leaf) => leaf.startsWith(selectedTenant)), + [liveSpecNames, storageMappings, selectedTenant] + ); + + return { leaves, selectedTenant }; +} diff --git a/src/context/Router/index.tsx b/src/context/Router/index.tsx index a597dd247e..b00639edbd 100644 --- a/src/context/Router/index.tsx +++ b/src/context/Router/index.tsx @@ -89,6 +89,9 @@ const MaterializationDetailsRoute = lazy( const MaterializationEditRoute = lazy( () => import('src/context/Router/MaterializationEdit') ); +const ServiceAccountDetailsRoute = lazy( + () => import('src/components/admin/ServiceAccounts/Details') +); const router = createBrowserRouter( createRoutesFromElements( @@ -757,6 +760,18 @@ const router = createBrowserRouter( } /> + + + + + + } + /> ; lastUsedAt?: Maybe; tokens: Array; updatedAt: Scalars['DateTime']['output']; @@ -1755,6 +1789,20 @@ export type ServiceAccountEdge = { node: ServiceAccount; }; +/** + * A user_grant held by a service account: the prefix it may act on and the + * capability it holds there. An account's access is the union of its grants, + * which may span multiple prefixes independent of its catalog_name anchor. + */ +export type ServiceAccountGrant = { + __typename?: 'ServiceAccountGrant'; + capability: Capability; + createdAt: Scalars['DateTime']['output']; + detail?: Maybe; + prefix: Scalars['Prefix']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + /** A user_grant to seed a service account with at creation time. */ export type ServiceAccountGrantInput = { capability: Capability; @@ -2239,6 +2287,23 @@ export type RevokeServiceAccountTokenMutationVariables = Exact<{ export type RevokeServiceAccountTokenMutation = { __typename?: 'MutationRoot', revokeServiceAccountToken: boolean }; +export type AddServiceAccountGrantMutationVariables = Exact<{ + catalogName: Scalars['Name']['input']; + prefix: Scalars['Prefix']['input']; + capability: Capability; +}>; + + +export type AddServiceAccountGrantMutation = { __typename?: 'MutationRoot', addServiceAccountGrant: boolean }; + +export type RemoveServiceAccountGrantMutationVariables = Exact<{ + catalogName: Scalars['Name']['input']; + prefix: Scalars['Prefix']['input']; +}>; + + +export type RemoveServiceAccountGrantMutation = { __typename?: 'MutationRoot', removeServiceAccountGrant: boolean }; + export type CreateStorageMappingMutationVariables = Exact<{ catalogPrefix: Scalars['Prefix']['input']; spec: Scalars['JSON']['input']; @@ -2333,6 +2398,8 @@ export const ServiceAccountsDocument = {"kind":"Document","definitions":[{"kind" export const CreateServiceAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"grants"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountGrantInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"grants"},"value":{"kind":"Variable","name":{"kind":"Name","value":"grants"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"}}]}}]}}]} as unknown as DocumentNode; export const CreateServiceAccountTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccountToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"createServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}},{"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 RevokeServiceAccountTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeServiceAccountToken"},"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":"revokeServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; +export const AddServiceAccountGrantDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddServiceAccountGrant"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"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":"capability"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Capability"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addServiceAccountGrant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"capability"},"value":{"kind":"Variable","name":{"kind":"Name","value":"capability"}}}]}]}}]} as unknown as DocumentNode; +export const RemoveServiceAccountGrantDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveServiceAccountGrant"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"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":"removeServiceAccountGrant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}}]}]}}]} 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 1557987c25..52ee5a12de 100644 --- a/src/gql-types/schema.graphql +++ b/src/gql-types/schema.graphql @@ -1153,6 +1153,18 @@ type MutationRoot { """ redeemInviteLink(token: UUID!): RedeemInviteLinkResult! + """ + Remove ALL user_grants from a service account, stripping its access in + one call. + + The caller must manage the service account (ManageServiceAccount on its + catalog name). As with removeServiceAccountGrant, no capability on the + grants' prefixes is required: removal only narrows access, so a manager + may clear grants to prefixes they don't themselves administer. Returns + the number of grants removed (0 if the account had none — not an error). + """ + removeAllServiceAccountGrants(catalogName: Name!): Int! + """ Remove a user_grant from a service account. @@ -1164,6 +1176,19 @@ type MutationRoot { """ removeServiceAccountGrant(catalogName: Name!, prefix: Prefix!): Boolean! + """ + Revoke ALL of a service account's tokens at once — the credential kill + switch. + + The caller must have ManageServiceAccount on the account's catalog name. + Like revokeServiceAccountToken, each token is made inert by zeroing its + `valid_for` interval (preserving the audit trail) rather than deleted; + already-revoked tokens are skipped. A service account's user_id only ever + owns its own minted credentials, so this targets exactly those. Returns + the number of tokens revoked (0 if none were active — not an error). + """ + revokeAllServiceAccountTokens(catalogName: Name!): Int! + """ Revoke a refresh token owned by the authenticated user. @@ -1609,6 +1634,7 @@ type ServiceAccount { catalogName: Name! createdAt: DateTime! createdBy: UUID! + grants: [ServiceAccountGrant!]! lastUsedAt: DateTime tokens: [ServiceAccountTokenInfo!]! updatedAt: DateTime! @@ -1631,6 +1657,19 @@ type ServiceAccountEdge { node: ServiceAccount! } +""" +A user_grant held by a service account: the prefix it may act on and the +capability it holds there. An account's access is the union of its grants, +which may span multiple prefixes independent of its catalog_name anchor. +""" +type ServiceAccountGrant { + capability: Capability! + createdAt: DateTime! + detail: String + prefix: Prefix! + updatedAt: DateTime! +} + """A user_grant to seed a service account with at creation time.""" input ServiceAccountGrantInput { capability: Capability! diff --git a/src/hooks/serviceAccounts/useServiceAccountGrants.ts b/src/hooks/serviceAccounts/useServiceAccountGrants.ts new file mode 100644 index 0000000000..50594dcb88 --- /dev/null +++ b/src/hooks/serviceAccounts/useServiceAccountGrants.ts @@ -0,0 +1,79 @@ +import type { + ServiceAccountGrant, + ServiceAccountGrantRow, +} from 'src/api/combinedGrantsExt'; + +import useSWR from 'swr'; + +import { + catalogNameFromServiceAccountEmail, + getServiceAccountGrants, + getServiceAccountGrantsByNames, +} from 'src/api/combinedGrantsExt'; + +const EMPTY_GRANTS: ServiceAccountGrant[] = []; +const EMPTY_GRANTS_BY_NAME: Record = {}; + +// Grants for a single service account (detail screen). The returned `mutate` +// lets callers revalidate after adding, editing, or removing a grant. +export function useServiceAccountGrants(catalogName: string | null) { + const { data, error, isLoading, isValidating, mutate } = useSWR( + catalogName ? ['service-account-grants', catalogName] : null, + async () => { + const { data: rows, error: queryError } = + await getServiceAccountGrants(catalogName as string); + + if (queryError) { + throw queryError; + } + + return rows ?? EMPTY_GRANTS; + } + ); + + return { + grants: data ?? EMPTY_GRANTS, + error, + fetching: isLoading, + isValidating, + mutate, + }; +} + +// Grants for every account visible on the list page, fetched in one query and +// grouped by the account catalog name (subject_role). +export function useServiceAccountGrantsByNames(catalogNames: string[]) { + const sortedNames = [...catalogNames].sort(); + + const { data, error, isLoading, mutate } = useSWR( + sortedNames.length + ? ['service-account-grants-batch', ...sortedNames] + : null, + async () => { + const { data: rows, error: queryError } = + await getServiceAccountGrantsByNames(catalogNames); + + if (queryError) { + throw queryError; + } + + return (rows ?? []).reduce>( + (grouped, row: ServiceAccountGrantRow) => { + const { user_email, ...grant } = row; + const catalogName = + catalogNameFromServiceAccountEmail(user_email); + (grouped[catalogName] ??= []).push(grant); + return grouped; + }, + {} + ); + } + ); + + return { + grantsByName: data ?? EMPTY_GRANTS_BY_NAME, + error, + fetching: isLoading, + mutate, + }; +} diff --git a/src/lang/en-US/RouteTitles.ts b/src/lang/en-US/RouteTitles.ts index 72ba859e0e..d2644b365c 100644 --- a/src/lang/en-US/RouteTitles.ts +++ b/src/lang/en-US/RouteTitles.ts @@ -8,6 +8,7 @@ export const RouteTitles: Record = { 'routeTitle.admin.api': `CLI - API`, 'routeTitle.admin.billing': `Billing`, 'routeTitle.admin.serviceAccounts': `Service Accounts`, + 'routeTitle.admin.serviceAccounts.details': `Service Account`, 'routeTitle.admin.settings': `Settings`, 'routeTitle.captureCreate': `Create Capture`, 'routeTitle.captureDetails': `Capture Details`, From 52878d6a9e424e837a5faa1e423309805055ad2e Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Sat, 20 Jun 2026 10:18:41 -0400 Subject: [PATCH 9/9] wip --- src/api/combinedGrantsExt.ts | 53 +- src/api/gql/serviceAccounts.ts | 19 + .../admin/ServiceAccounts/AccountCard.tsx | 7 +- .../ServiceAccounts/CompactAccountCard.tsx | 4 +- .../admin/ServiceAccounts/CreateDialog.tsx | 42 +- .../ServiceAccounts/Details/GrantsSection.tsx | 148 +- .../admin/ServiceAccounts/Details/index.tsx | 10 +- .../admin/ServiceAccounts/EmptyState.tsx | 2 +- .../admin/ServiceAccounts/GrantDialog.tsx | 2 +- .../ServiceAccounts/LifetimeSelector.tsx | 2 +- src/components/admin/ServiceAccounts/List.tsx | 27 +- .../ServiceAccounts/SecretRevealModal.tsx | 2 +- .../admin/ServiceAccounts/shared.ts | 8 +- src/context/Theme.tsx | 36 + src/gql-types/gql.ts | 12 +- src/gql-types/graphql.ts | 12 +- .../useServiceAccountGrants.ts | 79 - src/utils/alliterate-library.json | 1513 +++++++++++++++++ src/utils/alliterate.ts | 34 + 19 files changed, 1799 insertions(+), 213 deletions(-) delete mode 100644 src/hooks/serviceAccounts/useServiceAccountGrants.ts create mode 100644 src/utils/alliterate-library.json create mode 100644 src/utils/alliterate.ts diff --git a/src/api/combinedGrantsExt.ts b/src/api/combinedGrantsExt.ts index c23fd40524..c4c3e7aea1 100644 --- a/src/api/combinedGrantsExt.ts +++ b/src/api/combinedGrantsExt.ts @@ -131,55 +131,4 @@ const getUserInformationByPrefix = ( .returns(); }; -// A service account is a synthetic user, so its access grants surface in -// combined_grants_ext as user_grants — keyed by the account's user_email (which -// is the catalog name plus this domain), with subject_role null. object_role is -// the prefix the account can act on, at `capability`. -const SERVICE_ACCOUNT_EMAIL_DOMAIN = '@service_accounts.estuary.dev'; - -export function serviceAccountEmail(catalogName: string): string { - return `${catalogName}${SERVICE_ACCOUNT_EMAIL_DOMAIN}`; -} - -export function catalogNameFromServiceAccountEmail(email: string): string { - return email.endsWith(SERVICE_ACCOUNT_EMAIL_DOMAIN) - ? email.slice(0, -SERVICE_ACCOUNT_EMAIL_DOMAIN.length) - : email; -} - -export interface ServiceAccountGrant { - id: string; - object_role: string; - capability: Capability; - updated_at: string; -} - -export interface ServiceAccountGrantRow extends ServiceAccountGrant { - user_email: string; -} - -// Grants for a single service account, used by the detail screen. -const getServiceAccountGrants = (catalogName: string) => - supabaseClient - .from(TABLES.COMBINED_GRANTS_EXT) - .select('id, object_role, capability, updated_at') - .eq('user_email', serviceAccountEmail(catalogName)) - .order('object_role', { ascending: true }) - .returns(); - -// Grants for many accounts in one round trip, used by the list grid. Callers -// group the rows by user_email (mapped back to catalog name) on the client. -const getServiceAccountGrantsByNames = (catalogNames: string[]) => - supabaseClient - .from(TABLES.COMBINED_GRANTS_EXT) - .select('id, user_email, object_role, capability, updated_at') - .in('user_email', catalogNames.map(serviceAccountEmail)) - .returns(); - -export { - getGrants, - getGrants_Users, - getServiceAccountGrants, - getServiceAccountGrantsByNames, - getUserInformationByPrefix, -}; +export { getGrants, getGrants_Users, getUserInformationByPrefix }; diff --git a/src/api/gql/serviceAccounts.ts b/src/api/gql/serviceAccounts.ts index ecd358c971..820a73302c 100644 --- a/src/api/gql/serviceAccounts.ts +++ b/src/api/gql/serviceAccounts.ts @@ -20,6 +20,13 @@ const SERVICE_ACCOUNTS_QUERY = graphql(` createdBy updatedAt lastUsedAt + grants { + prefix + capability + createdAt + detail + updatedAt + } tokens { id detail @@ -162,6 +169,14 @@ const REMOVE_SERVICE_ACCOUNT_GRANT = graphql(` } `); +// Revokes every active token the account owns. Used when removing an account's +// last grant: a credential with no access left is worth retiring. +const REVOKE_ALL_SERVICE_ACCOUNT_TOKENS = graphql(` + mutation RevokeAllServiceAccountTokens($catalogName: Name!) { + revokeAllServiceAccountTokens(catalogName: $catalogName) + } +`); + export function useCreateServiceAccount() { return useMutation(CREATE_SERVICE_ACCOUNT); } @@ -181,3 +196,7 @@ export function useAddServiceAccountGrant() { export function useRemoveServiceAccountGrant() { return useMutation(REMOVE_SERVICE_ACCOUNT_GRANT); } + +export function useRevokeAllServiceAccountTokens() { + return useMutation(REVOKE_ALL_SERVICE_ACCOUNT_TOKENS); +} diff --git a/src/components/admin/ServiceAccounts/AccountCard.tsx b/src/components/admin/ServiceAccounts/AccountCard.tsx index 99bc7b1445..8500acf888 100644 --- a/src/components/admin/ServiceAccounts/AccountCard.tsx +++ b/src/components/admin/ServiceAccounts/AccountCard.tsx @@ -1,5 +1,4 @@ -import type { ServiceAccountGrant } from 'src/api/combinedGrantsExt'; -import type { ServiceAccount } from 'src/gql-types/graphql'; +import type { ServiceAccount, ServiceAccountGrant } from 'src/gql-types/graphql'; import type { SxProps, Theme } from '@mui/material'; import { Box, ButtonBase, Chip, Stack, Typography } from '@mui/material'; @@ -57,7 +56,7 @@ function AccountCard({ serviceAccount, grants, onOpen }: AccountCardProps) { 'width': '100%', 'textAlign': 'left', 'p': 2, - 'borderRadius': 3, + 'borderRadius': (theme) => theme.radius.lg, 'background': (theme) => hasGrants ? semiTransparentBackground[theme.palette.mode] @@ -88,7 +87,7 @@ function AccountCard({ serviceAccount, grants, onOpen }: AccountCardProps) { width: 42, height: 42, flex: 'none', - borderRadius: 2.5, + borderRadius: (theme) => theme.radius.md, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/src/components/admin/ServiceAccounts/CompactAccountCard.tsx b/src/components/admin/ServiceAccounts/CompactAccountCard.tsx index 01b887b3aa..44be60bdaa 100644 --- a/src/components/admin/ServiceAccounts/CompactAccountCard.tsx +++ b/src/components/admin/ServiceAccounts/CompactAccountCard.tsx @@ -25,7 +25,7 @@ function CompactAccountCard({ serviceAccount, onOpen }: CompactAccountCardProps) 'width': '100%', 'textAlign': 'left', 'p': 1.5, - 'borderRadius': 3, + 'borderRadius': (theme) => theme.radius.lg, 'background': 'transparent', 'border': (theme) => defaultOutline[theme.palette.mode], 'borderStyle': 'dashed', @@ -49,7 +49,7 @@ function CompactAccountCard({ serviceAccount, onOpen }: CompactAccountCardProps) width: 32, height: 32, flex: 'none', - borderRadius: 2, + borderRadius: (theme) => theme.radius.sm, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/src/components/admin/ServiceAccounts/CreateDialog.tsx b/src/components/admin/ServiceAccounts/CreateDialog.tsx index c5f71e83ff..68c5731904 100644 --- a/src/components/admin/ServiceAccounts/CreateDialog.tsx +++ b/src/components/admin/ServiceAccounts/CreateDialog.tsx @@ -1,7 +1,7 @@ import type { Capability } from 'src/types'; import type { SxProps, Theme } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Box, @@ -11,16 +11,18 @@ import { DialogContent, FormControlLabel, IconButton, + InputAdornment, Stack, Step, StepLabel, Stepper, Switch, TextField, + Tooltip, Typography, } from '@mui/material'; -import { NavArrowLeft, Plus, Trash } from 'iconoir-react'; +import { NavArrowLeft, Plus, Refresh, Trash } from 'iconoir-react'; import { useCreateServiceAccount, @@ -40,6 +42,7 @@ import { LeavesAutocomplete } from 'src/components/shared/LeavesAutocomplete/Lea import OutlinedToggleButton from 'src/components/shared/buttons/OutlinedToggleButton'; import OutlinedToggleButtonGroup from 'src/components/shared/OutlinedToggleButtonGroup'; import { codeBackground, defaultOutline } from 'src/context/Theme'; +import { generateAlliterativeName } from 'src/utils/alliterate'; import { hasLength } from 'src/utils/misc-utils'; const TITLE_ID = 'create-service-account'; @@ -72,7 +75,7 @@ const FULL_NAME_SX: SxProps = { gap: 1, px: 1.5, py: 1.25, - borderRadius: 1, + borderRadius: (theme) => theme.radius.sm, bgcolor: (theme) => codeBackground[theme.palette.mode], }; @@ -104,13 +107,17 @@ export function CreateServiceAccountDialog({ const [reveal, setReveal] = useState(null); const [createdName, setCreatedName] = useState(null); + const nameInputRef = useRef(null); + + const regenerateName = () => setName(generateAlliterativeName()); + useEffect(() => { if (!open) { return; } setLocalMode(mode); - setName(''); + setName(generateAlliterativeName()); setLocation(selectedTenant); setGrantOn(true); setQuickCapability('read'); @@ -224,11 +231,31 @@ export function CreateServiceAccountDialog({ event.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase() ) } + inputRef={nameInputRef} + autoFocus size="small" fullWidth required placeholder="banana-bot" helperText="Lowercase letters, numbers and dashes. Must be unique." + slotProps={{ + input: { + endAdornment: ( + + + + + + + + ), + }, + }} /> ); @@ -261,6 +288,11 @@ export function CreateServiceAccountDialog({ maxWidth="sm" fullWidth aria-labelledby={TITLE_ID} + slotProps={{ + transition: { + onEntered: () => nameInputRef.current?.select(), + }, + }} > theme.radius.md, border: (theme) => defaultOutline[theme.palette.mode], }} diff --git a/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx b/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx index 13155080ec..2b0d58653d 100644 --- a/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx +++ b/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx @@ -1,4 +1,4 @@ -import type { ServiceAccountGrant } from 'src/api/combinedGrantsExt'; +import type { ServiceAccountGrant } from 'src/gql-types/graphql'; import type { Capability } from 'src/types'; import { useState } from 'react'; @@ -6,10 +6,12 @@ import { useState } from 'react'; import { Box, Button, + Checkbox, Chip, Dialog, DialogActions, DialogContent, + FormControlLabel, IconButton, Stack, Typography, @@ -17,7 +19,10 @@ import { import { EditPencil, Folder, Lock, Plus, Trash } from 'iconoir-react'; -import { useRemoveServiceAccountGrant } from 'src/api/gql/serviceAccounts'; +import { + useRemoveServiceAccountGrant, + useRevokeAllServiceAccountTokens, +} from 'src/api/gql/serviceAccounts'; import GrantDialog from 'src/components/admin/ServiceAccounts/GrantDialog'; import { capabilityColor } from 'src/components/admin/ServiceAccounts/shared'; import { usePrefixLeaves } from 'src/components/admin/ServiceAccounts/usePrefixLeaves'; @@ -26,6 +31,9 @@ import AlertBox from 'src/components/shared/AlertBox'; interface GrantsSectionProps { catalogName: string; grants: ServiceAccountGrant[]; + // Number of API keys the account owns, used to offer revoking them when the + // last grant is removed. + tokenCount: number; onChanged: () => void; } @@ -35,16 +43,35 @@ interface GrantDialogState { capability?: Capability; } -function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { +function GrantsSection({ + catalogName, + grants, + tokenCount, + onChanged, +}: GrantsSectionProps) { const { leaves } = usePrefixLeaves(); const [dialog, setDialog] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); + const [revokeKeysToo, setRevokeKeysToo] = useState(false); const [removeError, setRemoveError] = useState(null); const [{ fetching: removing }, removeServiceAccountGrant] = useRemoveServiceAccountGrant(); + const [{ fetching: revoking }, revokeAllServiceAccountTokens] = + useRevokeAllServiceAccountTokens(); + + // Removing the only remaining grant leaves the account with no access. + const removingLastGrant = grants.length === 1; + const offerRevokeKeys = removingLastGrant && tokenCount > 0; + const busy = removing || revoking; + + const openRemove = (grant: ServiceAccountGrant) => { + setRemoveError(null); + setRevokeKeysToo(false); + setRemoveTarget(grant); + }; const handleRemove = async () => { if (!removeTarget) { @@ -55,7 +82,7 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { const result = await removeServiceAccountGrant({ catalogName, - prefix: removeTarget.object_role, + prefix: removeTarget.prefix, }); if (result.error) { @@ -63,6 +90,21 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { return; } + if (offerRevokeKeys && revokeKeysToo) { + const revokeResult = await revokeAllServiceAccountTokens({ + catalogName, + }); + + if (revokeResult.error) { + // The grant is already gone; surface the partial failure. + onChanged(); + setRemoveError( + `The grant was removed, but the API keys could not be revoked: ${revokeResult.error.message}` + ); + return; + } + } + setRemoveTarget(null); onChanged(); }; @@ -110,7 +152,7 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { ) : ( grants.map((grant) => ( - {grant.object_role} + {grant.prefix} setDialog({ mode: 'edit', - prefix: grant.object_role, - capability: grant.capability, + prefix: grant.prefix, + capability: grant.capability as Capability, }) } > @@ -157,10 +199,7 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { { - setRemoveError(null); - setRemoveTarget(grant); - }} + onClick={() => openRemove(grant)} > @@ -192,7 +231,7 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { width: 36, height: 36, flex: 'none', - borderRadius: 2, + borderRadius: (theme) => theme.radius.md, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -202,27 +241,68 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { >
- - - Remove access grant? - - - This account will immediately lose access to{' '} + + + + Remove access grant? + + + This account will immediately lose access to{' '} + + {removeTarget?.prefix} + + . Existing API keys will stop working for + this prefix. + + + + {offerRevokeKeys ? ( theme.radius.md, + border: (theme) => + `1px solid ${theme.palette.divider}`, }} > - {removeTarget?.object_role} + + setRevokeKeysToo( + event.target.checked + ) + } + sx={{ pt: 0 }} + /> + } + label={ + + This is the account’s last grant. + Also revoke its{' '} + {tokenCount === 1 + ? '1 API key' + : `${tokenCount} API keys`}{' '} + so the leftover credential can’t + be used. + + } + /> - . Existing API keys will stop working for this - prefix. - + ) : null} + {removeError ? ( {removeError} @@ -235,7 +315,7 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { @@ -243,10 +323,12 @@ function GrantsSection({ catalogName, grants, onChanged }: GrantsSectionProps) { variant="contained" color="error" onClick={handleRemove} - disabled={removing} - loading={removing} + disabled={busy} + loading={busy} > - Remove grant + {revokeKeysToo && offerRevokeKeys + ? 'Remove grant & revoke keys' + : 'Remove grant'} diff --git a/src/components/admin/ServiceAccounts/Details/index.tsx b/src/components/admin/ServiceAccounts/Details/index.tsx index 424ab6bc63..8fdb2279cf 100644 --- a/src/components/admin/ServiceAccounts/Details/index.tsx +++ b/src/components/admin/ServiceAccounts/Details/index.tsx @@ -25,7 +25,6 @@ import { logoColors } from 'src/context/Theme'; import useGlobalSearchParams, { GlobalSearchParams, } from 'src/hooks/searchParams/useGlobalSearchParams'; -import { useServiceAccountGrants } from 'src/hooks/serviceAccounts/useServiceAccountGrants'; import usePageTitle from 'src/hooks/usePageTitle'; const META_LABEL_SX = { @@ -59,8 +58,6 @@ function ServiceAccountDetails() { const { serviceAccount, fetching, refetch } = useServiceAccount(catalogName); - const { grants, mutate: mutateGrants } = - useServiceAccountGrants(catalogName); const [createKeyOpen, setCreateKeyOpen] = useState(false); @@ -109,7 +106,7 @@ function ServiceAccountDetails() { width: 52, height: 52, flex: 'none', - borderRadius: 3, + borderRadius: (theme) => theme.radius.md, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -191,8 +188,9 @@ function ServiceAccountDetails() { diff --git a/src/components/admin/ServiceAccounts/EmptyState.tsx b/src/components/admin/ServiceAccounts/EmptyState.tsx index 04b5c9987b..2ce47f1cc7 100644 --- a/src/components/admin/ServiceAccounts/EmptyState.tsx +++ b/src/components/admin/ServiceAccounts/EmptyState.tsx @@ -23,7 +23,7 @@ function EmptyState({ onQuickCreate, onGuidedCreate }: EmptyStateProps) { sx={{ width: 88, height: 88, - borderRadius: 2.5, + borderRadius: (theme) => theme.radius.xl, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/src/components/admin/ServiceAccounts/GrantDialog.tsx b/src/components/admin/ServiceAccounts/GrantDialog.tsx index 7eb1a916a4..57757f3ea4 100644 --- a/src/components/admin/ServiceAccounts/GrantDialog.tsx +++ b/src/components/admin/ServiceAccounts/GrantDialog.tsx @@ -133,7 +133,7 @@ function GrantDialog({ alignItems: 'center', px: 1.5, py: 1, - borderRadius: 1, + borderRadius: (theme) => theme.radius.sm, color: 'text.secondary', bgcolor: (theme) => codeBackground[theme.palette.mode], diff --git a/src/components/admin/ServiceAccounts/LifetimeSelector.tsx b/src/components/admin/ServiceAccounts/LifetimeSelector.tsx index 8b374ed464..fb8ee82391 100644 --- a/src/components/admin/ServiceAccounts/LifetimeSelector.tsx +++ b/src/components/admin/ServiceAccounts/LifetimeSelector.tsx @@ -17,7 +17,7 @@ function LifetimeSelector({ value, onChange }: LifetimeSelectorProps) { size="small" variant={value === option.value ? 'contained' : 'outlined'} onClick={() => onChange(option.value)} - sx={{ borderRadius: 5 }} + sx={{ borderRadius: (theme) => theme.radius.full }} > {option.label} diff --git a/src/components/admin/ServiceAccounts/List.tsx b/src/components/admin/ServiceAccounts/List.tsx index 65a8655dc7..efc746f7e8 100644 --- a/src/components/admin/ServiceAccounts/List.tsx +++ b/src/components/admin/ServiceAccounts/List.tsx @@ -20,7 +20,6 @@ import { CreateServiceAccountDialog } from 'src/components/admin/ServiceAccounts import EmptyState from 'src/components/admin/ServiceAccounts/EmptyState'; import AlertBox from 'src/components/shared/AlertBox'; import { GlobalSearchParams } from 'src/hooks/searchParams/useGlobalSearchParams'; -import { useServiceAccountGrantsByNames } from 'src/hooks/serviceAccounts/useServiceAccountGrants'; import { useCursorPagination } from 'src/hooks/useCursorPagination'; type CreateMode = 'quick' | 'guided'; @@ -32,11 +31,6 @@ export function ServiceAccountsList() { const { serviceAccounts, fetching, error, pageInfo, pageSize } = useServiceAccounts(cursor); - const { grantsByName, fetching: grantsFetching } = - useServiceAccountGrantsByNames( - serviceAccounts.map((account) => account.catalogName) - ); - const [createOpen, setCreateOpen] = useState(false); const [createMode, setCreateMode] = useState('quick'); @@ -55,18 +49,13 @@ export function ServiceAccountsList() { const from = currentPage * pageSize + 1; const to = from + serviceAccounts.length - 1; - const hasGrants = (catalogName: string) => - (grantsByName[catalogName]?.length ?? 0) > 0; - // Accounts without any grants are de-emphasized and grouped at the bottom. - // Until grants finish loading we can't tell them apart, so keep everything - // in the main grid to avoid a flash of compact cards. - const grantedAccounts = grantsFetching - ? serviceAccounts - : serviceAccounts.filter((account) => hasGrants(account.catalogName)); - const noAccessAccounts = grantsFetching - ? [] - : serviceAccounts.filter((account) => !hasGrants(account.catalogName)); + const grantedAccounts = serviceAccounts.filter( + (account) => account.grants.length > 0 + ); + const noAccessAccounts = serviceAccounts.filter( + (account) => account.grants.length === 0 + ); return ( @@ -140,9 +129,7 @@ export function ServiceAccountsList() { ))} diff --git a/src/components/admin/ServiceAccounts/SecretRevealModal.tsx b/src/components/admin/ServiceAccounts/SecretRevealModal.tsx index 5f5eaf8aca..b62a0f5ad2 100644 --- a/src/components/admin/ServiceAccounts/SecretRevealModal.tsx +++ b/src/components/admin/ServiceAccounts/SecretRevealModal.tsx @@ -68,7 +68,7 @@ function SecretRevealModal({ width: 38, height: 38, flex: 'none', - borderRadius: 2.5, + borderRadius: (theme) => theme.radius.md, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/src/components/admin/ServiceAccounts/shared.ts b/src/components/admin/ServiceAccounts/shared.ts index 6d534ea687..65f5230408 100644 --- a/src/components/admin/ServiceAccounts/shared.ts +++ b/src/components/admin/ServiceAccounts/shared.ts @@ -6,14 +6,16 @@ import { DateTime, Duration } from 'luxon'; export type CapabilityColor = 'info' | 'primary' | 'warning'; // How each capability reads. read is the gentlest (info), write is the working -// default (primary), admin is the most privileged (warning). -const CAPABILITY_COLOR: Record = { +// default (primary), admin is the most privileged (warning). Accepts a string +// since grant capabilities come from the GraphQL `Capability` enum, which also +// includes `none`. +const CAPABILITY_COLOR: Record = { read: 'info', write: 'primary', admin: 'warning', }; -export function capabilityColor(capability: Capability): CapabilityColor { +export function capabilityColor(capability: string): CapabilityColor { return CAPABILITY_COLOR[capability] ?? 'info'; } diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 62174da645..626ee40845 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -91,6 +91,35 @@ declare module '@mui/material/Typography' { } } +// Border-radius scale. Roundness tracks an element's size and prominence: +// larger, more container-like surfaces get more rounding, small controls get +// less, and `full` fully rounds pills/toggles. Values are px strings so they +// read literally in `sx`/`styled` `borderRadius` — a *number* there is +// multiplied by `theme.shape.borderRadius`, a string is used as-is. When +// nesting a rounded element in the corner of another, keep corners concentric: +// inner radius = outer radius − padding. +export interface RadiusScale { + /** 4px — small insets: code strips, option panels, compact tiles */ + sm: string; + /** 8px — monogram/icon tiles, settings panels */ + md: string; + /** 12px — cards */ + lg: string; + /** 16px — large or prominent surfaces (page containers, hero blocks) */ + xl: string; + /** Fully rounded — pills, toggle buttons */ + full: string; +} + +declare module '@mui/material/styles' { + interface Theme { + radius: RadiusScale; + } + interface ThemeOptions { + radius?: RadiusScale; + } +} + // Navigation Width export enum NavWidths { MOBILE = 0, @@ -1062,6 +1091,13 @@ const themeSettings = createTheme({ shape: { borderRadius: 2, }, + radius: { + sm: '4px', + md: '8px', + lg: '12px', + xl: '16px', + full: '9999px', + }, typography: { fontFamily: [ 'Inter', diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index e90659576f..a68b27c3c4 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -30,12 +30,13 @@ type 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": 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 query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": typeof types.ServiceAccountsDocument, + "\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n grants {\n prefix\n capability\n createdAt\n detail\n updatedAt\n }\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": typeof types.ServiceAccountsDocument, "\n mutation CreateServiceAccount(\n $catalogName: Name!\n $grants: [ServiceAccountGrantInput!]!\n ) {\n createServiceAccount(catalogName: $catalogName, grants: $grants) {\n catalogName\n createdAt\n createdBy\n }\n }\n": typeof types.CreateServiceAccountDocument, "\n mutation CreateServiceAccountToken(\n $catalogName: Name!\n $detail: String!\n $validFor: String!\n ) {\n createServiceAccountToken(\n catalogName: $catalogName\n detail: $detail\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n": typeof types.CreateServiceAccountTokenDocument, "\n mutation RevokeServiceAccountToken($id: Id!) {\n revokeServiceAccountToken(id: $id)\n }\n": typeof types.RevokeServiceAccountTokenDocument, "\n mutation AddServiceAccountGrant(\n $catalogName: Name!\n $prefix: Prefix!\n $capability: Capability!\n ) {\n addServiceAccountGrant(\n catalogName: $catalogName\n prefix: $prefix\n capability: $capability\n )\n }\n": typeof types.AddServiceAccountGrantDocument, "\n mutation RemoveServiceAccountGrant($catalogName: Name!, $prefix: Prefix!) {\n removeServiceAccountGrant(catalogName: $catalogName, prefix: $prefix)\n }\n": typeof types.RemoveServiceAccountGrantDocument, + "\n mutation RevokeAllServiceAccountTokens($catalogName: Name!) {\n revokeAllServiceAccountTokens(catalogName: $catalogName)\n }\n": typeof types.RevokeAllServiceAccountTokensDocument, "\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, @@ -64,12 +65,13 @@ const documents: 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": 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 query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": types.ServiceAccountsDocument, + "\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n grants {\n prefix\n capability\n createdAt\n detail\n updatedAt\n }\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n": types.ServiceAccountsDocument, "\n mutation CreateServiceAccount(\n $catalogName: Name!\n $grants: [ServiceAccountGrantInput!]!\n ) {\n createServiceAccount(catalogName: $catalogName, grants: $grants) {\n catalogName\n createdAt\n createdBy\n }\n }\n": types.CreateServiceAccountDocument, "\n mutation CreateServiceAccountToken(\n $catalogName: Name!\n $detail: String!\n $validFor: String!\n ) {\n createServiceAccountToken(\n catalogName: $catalogName\n detail: $detail\n validFor: $validFor\n ) {\n id\n secret\n }\n }\n": types.CreateServiceAccountTokenDocument, "\n mutation RevokeServiceAccountToken($id: Id!) {\n revokeServiceAccountToken(id: $id)\n }\n": types.RevokeServiceAccountTokenDocument, "\n mutation AddServiceAccountGrant(\n $catalogName: Name!\n $prefix: Prefix!\n $capability: Capability!\n ) {\n addServiceAccountGrant(\n catalogName: $catalogName\n prefix: $prefix\n capability: $capability\n )\n }\n": types.AddServiceAccountGrantDocument, "\n mutation RemoveServiceAccountGrant($catalogName: Name!, $prefix: Prefix!) {\n removeServiceAccountGrant(catalogName: $catalogName, prefix: $prefix)\n }\n": types.RemoveServiceAccountGrantDocument, + "\n mutation RevokeAllServiceAccountTokens($catalogName: Name!) {\n revokeAllServiceAccountTokens(catalogName: $catalogName)\n }\n": types.RevokeAllServiceAccountTokensDocument, "\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, @@ -163,7 +165,7 @@ export function graphql(source: "\n mutation RevokeRefreshToken($id: Id!) {\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 ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"): (typeof documents)["\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"]; +export function graphql(source: "\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n grants {\n prefix\n capability\n createdAt\n detail\n updatedAt\n }\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\n }\n cursor\n }\n pageInfo {\n ...PageInfoFields\n }\n }\n }\n"): (typeof documents)["\n query ServiceAccounts($first: Int, $after: String) {\n serviceAccounts(first: $first, after: $after) {\n edges {\n node {\n catalogName\n createdAt\n createdBy\n updatedAt\n lastUsedAt\n grants {\n prefix\n capability\n createdAt\n detail\n updatedAt\n }\n tokens {\n id\n detail\n createdAt\n createdBy\n expiresAt\n lastUsedAt\n }\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. */ @@ -184,6 +186,10 @@ export function graphql(source: "\n mutation AddServiceAccountGrant(\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 RemoveServiceAccountGrant($catalogName: Name!, $prefix: Prefix!) {\n removeServiceAccountGrant(catalogName: $catalogName, prefix: $prefix)\n }\n"): (typeof documents)["\n mutation RemoveServiceAccountGrant($catalogName: Name!, $prefix: Prefix!) {\n removeServiceAccountGrant(catalogName: $catalogName, prefix: $prefix)\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 RevokeAllServiceAccountTokens($catalogName: Name!) {\n revokeAllServiceAccountTokens(catalogName: $catalogName)\n }\n"): (typeof documents)["\n mutation RevokeAllServiceAccountTokens($catalogName: Name!) {\n revokeAllServiceAccountTokens(catalogName: $catalogName)\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 c53b35b34d..ffcbe9aa75 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -2261,7 +2261,7 @@ export type ServiceAccountsQueryVariables = Exact<{ }>; -export type ServiceAccountsQuery = { __typename?: 'QueryRoot', serviceAccounts: { __typename?: 'ServiceAccountConnection', edges: Array<{ __typename?: 'ServiceAccountEdge', cursor: string, node: { __typename?: 'ServiceAccount', catalogName: any, createdAt: any, createdBy: any, updatedAt: any, lastUsedAt?: any | null, tokens: Array<{ __typename?: 'ServiceAccountTokenInfo', id: any, detail?: string | null, createdAt: any, createdBy: any, expiresAt: any, lastUsedAt?: any | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; +export type ServiceAccountsQuery = { __typename?: 'QueryRoot', serviceAccounts: { __typename?: 'ServiceAccountConnection', edges: Array<{ __typename?: 'ServiceAccountEdge', cursor: string, node: { __typename?: 'ServiceAccount', catalogName: any, createdAt: any, createdBy: any, updatedAt: any, lastUsedAt?: any | null, grants: Array<{ __typename?: 'ServiceAccountGrant', prefix: any, capability: Capability, createdAt: any, detail?: string | null, updatedAt: any }>, tokens: Array<{ __typename?: 'ServiceAccountTokenInfo', id: any, detail?: string | null, createdAt: any, createdBy: any, expiresAt: any, lastUsedAt?: any | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; export type CreateServiceAccountMutationVariables = Exact<{ catalogName: Scalars['Name']['input']; @@ -2304,6 +2304,13 @@ export type RemoveServiceAccountGrantMutationVariables = Exact<{ export type RemoveServiceAccountGrantMutation = { __typename?: 'MutationRoot', removeServiceAccountGrant: boolean }; +export type RevokeAllServiceAccountTokensMutationVariables = Exact<{ + catalogName: Scalars['Name']['input']; +}>; + + +export type RevokeAllServiceAccountTokensMutation = { __typename?: 'MutationRoot', revokeAllServiceAccountTokens: number }; + export type CreateStorageMappingMutationVariables = Exact<{ catalogPrefix: Scalars['Prefix']['input']; spec: Scalars['JSON']['input']; @@ -2394,12 +2401,13 @@ export const LiveSpecsQueryDocument = {"kind":"Document","definitions":[{"kind": 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 ServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServiceAccounts"},"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":"serviceAccounts"},"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":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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":"createdBy"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}}]}}]}},{"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 ServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServiceAccounts"},"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":"serviceAccounts"},"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":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}},{"kind":"Field","name":{"kind":"Name","value":"grants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prefix"}},{"kind":"Field","name":{"kind":"Name","value":"capability"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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":"createdBy"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}}]}}]}},{"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 CreateServiceAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"grants"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountGrantInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"grants"},"value":{"kind":"Variable","name":{"kind":"Name","value":"grants"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"}}]}}]}}]} as unknown as DocumentNode; export const CreateServiceAccountTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccountToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"createServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}},{"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 RevokeServiceAccountTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeServiceAccountToken"},"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":"revokeServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const AddServiceAccountGrantDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddServiceAccountGrant"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"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":"capability"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Capability"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addServiceAccountGrant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"capability"},"value":{"kind":"Variable","name":{"kind":"Name","value":"capability"}}}]}]}}]} as unknown as DocumentNode; export const RemoveServiceAccountGrantDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveServiceAccountGrant"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}},{"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":"removeServiceAccountGrant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}}]}]}}]} as unknown as DocumentNode; +export const RevokeAllServiceAccountTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeAllServiceAccountTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Name"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"revokeAllServiceAccountTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"catalogName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"catalogName"}}}]}]}}]} 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/hooks/serviceAccounts/useServiceAccountGrants.ts b/src/hooks/serviceAccounts/useServiceAccountGrants.ts deleted file mode 100644 index 50594dcb88..0000000000 --- a/src/hooks/serviceAccounts/useServiceAccountGrants.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { - ServiceAccountGrant, - ServiceAccountGrantRow, -} from 'src/api/combinedGrantsExt'; - -import useSWR from 'swr'; - -import { - catalogNameFromServiceAccountEmail, - getServiceAccountGrants, - getServiceAccountGrantsByNames, -} from 'src/api/combinedGrantsExt'; - -const EMPTY_GRANTS: ServiceAccountGrant[] = []; -const EMPTY_GRANTS_BY_NAME: Record = {}; - -// Grants for a single service account (detail screen). The returned `mutate` -// lets callers revalidate after adding, editing, or removing a grant. -export function useServiceAccountGrants(catalogName: string | null) { - const { data, error, isLoading, isValidating, mutate } = useSWR( - catalogName ? ['service-account-grants', catalogName] : null, - async () => { - const { data: rows, error: queryError } = - await getServiceAccountGrants(catalogName as string); - - if (queryError) { - throw queryError; - } - - return rows ?? EMPTY_GRANTS; - } - ); - - return { - grants: data ?? EMPTY_GRANTS, - error, - fetching: isLoading, - isValidating, - mutate, - }; -} - -// Grants for every account visible on the list page, fetched in one query and -// grouped by the account catalog name (subject_role). -export function useServiceAccountGrantsByNames(catalogNames: string[]) { - const sortedNames = [...catalogNames].sort(); - - const { data, error, isLoading, mutate } = useSWR( - sortedNames.length - ? ['service-account-grants-batch', ...sortedNames] - : null, - async () => { - const { data: rows, error: queryError } = - await getServiceAccountGrantsByNames(catalogNames); - - if (queryError) { - throw queryError; - } - - return (rows ?? []).reduce>( - (grouped, row: ServiceAccountGrantRow) => { - const { user_email, ...grant } = row; - const catalogName = - catalogNameFromServiceAccountEmail(user_email); - (grouped[catalogName] ??= []).push(grant); - return grouped; - }, - {} - ); - } - ); - - return { - grantsByName: data ?? EMPTY_GRANTS_BY_NAME, - error, - fetching: isLoading, - mutate, - }; -} diff --git a/src/utils/alliterate-library.json b/src/utils/alliterate-library.json new file mode 100644 index 0000000000..3ab54e22b9 --- /dev/null +++ b/src/utils/alliterate-library.json @@ -0,0 +1,1513 @@ +{ + "modifiers": { + "a": [ + "amazing", + "adventurous", + "antsy", + "artsy", + "agile", + "alluring", + "animated", + "ambitious", + "airy", + "astonishing", + "appealing", + "ambling", + "applauding", + "arabesquing", + "awing" + ], + "b": [ + "bubbly", + "bouncy", + "bold", + "breezy", + "bright", + "brave", + "bizarre", + "beaming", + "blissful", + "buoyant", + "bamboozling", + "bumbling", + "bouncing", + "boinging", + "bedazzling", + "bobbing", + "bubbling", + "blubbering", + "bopping", + "bewitching", + "bantering", + "bellowing", + "blustering", + "brandishing" + ], + "c": [ + "cuddly", + "clever", + "cozy", + "crazy", + "colorful", + "crafty", + "cavorting", + "cackling", + "capering", + "canoodling", + "clambering", + "clattering", + "careening", + "cartwheeling", + "catapulting", + "clowning", + "conjuring" + ], + "ch": [ + "cheerful", + "charming", + "cheeky", + "chipper", + "chortling", + "chuckling" + ], + "d": [ + "dazzling", + "dreamy", + "daring", + "delightful", + "dapper", + "dynamic", + "ditzy", + "dandy", + "divine", + "dramatic", + "dawdling", + "dilly-dallying", + "dabbling", + "doodling", + "dithering", + "dodging", + "drumming", + "dunking", + "daydreaming", + "dribbling" + ], + "e": [ + "energetic", + "eccentric", + "excitable", + "electric", + "elegant", + "enchanting", + "eager", + "epic", + "exuberant", + "easygoing", + "effervescent", + "expressive", + "exclaiming", + "expounding", + "effervescing", + "eddying", + "elbowing", + "exulting", + "eyeballing" + ], + "f": [ + "fabulous", + "funky", + "fluffy", + "friendly", + "fierce", + "fancy", + "festive", + "flashy", + "fearless", + "frisky", + "flouncing", + "frolicking", + "fiddling", + "fidgeting", + "flailing", + "flapping", + "fluttering", + "frothing", + "fumbling", + "fizzing", + "finagling" + ], + "g": [ + "goofy", + "giddy", + "glittery", + "groovy", + "gleeful", + "gutsy", + "glamorous", + "gentle", + "glowing", + "graceful", + "galumphing", + "gallivanting", + "giggling", + "guffawing", + "gamboling", + "gawking", + "gobbling", + "goofing", + "grinning", + "gurgling", + "gushing", + "gyrating", + "grooving" + ], + "h": [ + "happy", + "hilarious", + "hyper", + "heroic", + "huggable", + "hopeful", + "hearty", + "hip", + "harmonious", + "humorous", + "hobnobbing", + "hopscotching", + "hollering", + "hooting", + "hovering", + "hula-hooping", + "humming", + "hopping", + "hustling", + "hoodwinking", + "harrumphing", + "hankering", + "hightailing", + "hobbling" + ], + "i": [ + "incredible", + "imaginative", + "infectious", + "inventive", + "impish", + "irresistible", + "intriguing", + "illuminating", + "inspiring", + "impressive", + "improvising", + "idolizing", + "inveigling", + "itching", + "igniting", + "imagining", + "intermingling" + ], + "j": [ + "jolly", + "jazzy", + "jovial", + "joyful", + "jubilant", + "jumpy", + "jaunty", + "jocular", + "juicy", + "jiggly", + "jamboreeing", + "jitterbugging", + "jiving", + "jostling", + "juggling", + "jingling", + "jouncing", + "jabbering", + "jaunting", + "jollifying", + "jubilating" + ], + "k": [ + "kooky", + "kind", + "keen", + "kicky", + "knockout", + "kissable", + "kindhearted", + "kinetic", + "knowing", + "kindly", + "kibitzing", + "kvetching", + "kerplunking", + "kowtowing", + "kicking", + "knuckling", + "kindling" + ], + "l": [ + "lively", + "lovable", + "lighthearted", + "luminous", + "lucky", + "ludicrous", + "luscious", + "leaping", + "legendary", + "limber", + "larking", + "lollygagging", + "leapfrogging", + "lilting", + "lollopping", + "lounging", + "loitering", + "lampooning", + "lurching", + "lumbering", + "lassoing" + ], + "m": [ + "magical", + "merry", + "mischievous", + "magnificent", + "marvelous", + "mighty", + "mellow", + "melodious", + "motivated", + "mesmerizing", + "meandering", + "moseying", + "mingling", + "marveling", + "mooching", + "mumbling", + "munching", + "moonwalking", + "masquerading", + "monkeying", + "mollycoddling" + ], + "n": [ + "nifty", + "nimble", + "neat", + "noble", + "nutty", + "nurturing", + "natty", + "nice", + "novel", + "notable", + "noodling", + "nuzzling", + "nitpicking", + "nibbling", + "nodding", + "narrating", + "nesting", + "nosediving" + ], + "o": [ + "outgoing", + "optimistic", + "original", + "outstanding", + "outrageous", + "opulent", + "obliging", + "open", + "observant", + "overjoyed", + "ogling", + "oohing", + "oozing", + "orbiting", + "oscillating", + "overflowing", + "outfoxing", + "ornamenting" + ], + "p": [ + "playful", + "peppy", + "perky", + "plucky", + "punchy", + "peaceful", + "polished", + "posh", + "positive", + "passionate", + "prancing", + "pirouetting", + "pottering", + "puttering", + "parading", + "pranking", + "primping", + "pondering", + "plopping", + "plummeting", + "pussyfooting" + ], + "ph": [ + "philosophizing", + "phenomenal", + "photogenic", + "phantasmal" + ], + "q": [ + "quirky", + "quick", + "quaint", + "quizzical", + "quotable", + "quivery", + "queenly", + "questing", + "quintessential", + "quick-witted", + "quaffing", + "quibbling", + "quipping", + "quirking", + "quivering", + "quickstepping" + ], + "r": [ + "radiant", + "rambunctious", + "robust", + "rosy", + "rad", + "ravishing", + "resilient", + "refreshing", + "rollicking", + "riveting", + "romping", + "rambling", + "ricocheting", + "rummaging", + "rampaging", + "riffing", + "roaming", + "rollerskating", + "ruminating", + "rejoicing" + ], + "s": [ + "silly", + "sparkly", + "spunky", + "snazzy", + "sassy", + "sunny", + "sprightly", + "snappy", + "smooth", + "spirited", + "sashaying", + "scampering", + "skedaddling", + "scuttling", + "swashbuckling", + "somersaulting", + "swirling", + "swooping", + "swaggering", + "snickering", + "sauntering", + "spelunking", + "serenading" + ], + "sh": [ + "shimmying", + "shimmering", + "shiny", + "showy", + "sharp", + "shuffling" + ], + "t": [ + "terrific", + "tickled", + "tenacious", + "twinkly", + "tidy", + "talented", + "tantalizing", + "trusty", + "tender", + "traipsing", + "tiptoeing", + "tumbling", + "twirling", + "tittering", + "tinkering", + "trundling", + "tobogganing", + "teetering", + "twiddling", + "trapezing", + "trotting" + ], + "th": [ + "thrilling", + "thundering", + "thoughtful", + "thriving", + "thumping" + ], + "u": [ + "upbeat", + "unique", + "unstoppable", + "ultra", + "uplifting", + "unflappable", + "untamed", + "useful", + "unforgettable", + "uproarious", + "unfurling", + "undulating", + "ululating", + "upcycling", + "unraveling", + "unspooling" + ], + "v": [ + "vibrant", + "vivacious", + "valiant", + "velvety", + "vigorous", + "victorious", + "vivid", + "versatile", + "vital", + "virtuous", + "vamping", + "vaulting", + "venturing", + "vanishing", + "vexing", + "voyaging", + "vibrating", + "vrooming" + ], + "w": [ + "wacky", + "wonderful", + "wiggly", + "witty", + "warm", + "wondrous", + "wild", + "winsome", + "winning", + "waltzing", + "wandering", + "wiggling", + "wobbling", + "wisecracking", + "windmilling", + "wallowing", + "wafting" + ], + "wh": [ + "whimsical", + "whirling", + "whooping", + "whittling", + "wheedling", + "whizzing" + ], + "x": [ + "xenial", + "xenodochial", + "x-cellent", + "extra", + "expressive", + "exemplary", + "x-raying", + "exquisite" + ], + "y": [ + "youthful", + "yummy", + "yearning", + "yippy", + "young-at-heart", + "yodeling", + "yo-yoing", + "yammering", + "yawping", + "yowling", + "yukking" + ], + "z": [ + "zany", + "zesty", + "zippy", + "zealous", + "zingy", + "zenlike", + "zappy", + "zazzy", + "zooming", + "zigzagging", + "zipping", + "zinging", + "ziplining", + "zonking", + "zapping", + "zesting" + ] + }, + "objects": { + "a": [ + "aardvark", + "albatross", + "alligator", + "alpaca", + "anaconda", + "anchovy", + "anemone", + "angelfish", + "anteater", + "antelope", + "ape", + "apricot", + "armadillo", + "aspen", + "aster", + "avocado", + "axolotl", + "azalea", + "anglerfish", + "antlion", + "agouti", + "akita", + "aloe", + "amaryllis", + "anhinga", + "anole", + "apple", + "avocet" + ], + "b": [ + "baboon", + "badger", + "barracuda", + "bass", + "bat", + "beagle", + "bear", + "beaver", + "bee", + "beech", + "beetle", + "begonia", + "betta", + "birch", + "bison", + "blackbird", + "bluebell", + "bluebird", + "bluejay", + "boa", + "bobcat", + "bonobo", + "bramble", + "buffalo", + "bumblebee", + "bunting", + "butterfly", + "buttercup", + "buzzard", + "bullfrog" + ], + "c": [ + "cactus", + "camel", + "canary", + "caracal", + "cardinal", + "caribou", + "carnation", + "carp", + "cassowary", + "cat", + "caterpillar", + "catfish", + "cedar", + "centipede", + "cicada", + "clam", + "clover", + "cobra", + "cockatoo", + "condor", + "coral", + "cormorant", + "cougar", + "coyote", + "crab", + "crane", + "crayfish", + "cricket", + "crocodile", + "crow", + "cuckoo", + "cypress" + ], + "ch": [ + "chameleon", + "cheetah", + "cherry", + "chickadee", + "chimpanzee", + "chinchilla", + "chipmunk" + ], + "d": [ + "dachshund", + "daffodil", + "dahlia", + "daisy", + "dalmatian", + "damselfly", + "dandelion", + "deer", + "dingo", + "dodo", + "dogwood", + "dolphin", + "donkey", + "dormouse", + "dove", + "dragonfly", + "dromedary", + "duck", + "dugong", + "dik-dik", + "delphinium", + "discus", + "drongo", + "dunlin", + "durian", + "daylily", + "dogfish", + "doodlebug" + ], + "e": [ + "eagle", + "earthworm", + "earwig", + "echidna", + "eel", + "egret", + "eider", + "eland", + "elderberry", + "elephant", + "elk", + "elm", + "emu", + "ermine", + "eucalyptus", + "edelweiss", + "eggplant", + "eft", + "elephant-seal", + "emperor-penguin", + "emperor-moth", + "euphorbia", + "ewe", + "eelgrass", + "eglantine", + "elephant-shrew", + "euglena", + "eulachon" + ], + "f": [ + "falcon", + "fennec-fox", + "fennel", + "fern", + "ferret", + "fig", + "finch", + "fir", + "firefly", + "flamingo", + "flax", + "flea", + "flounder", + "flycatcher", + "fox", + "foxglove", + "frangipani", + "freesia", + "frog", + "fuchsia", + "fallow-deer", + "fantail", + "fiddlehead", + "firethorn", + "flatworm", + "fleabane", + "flying-fish", + "flying-fox", + "forget-me-not", + "forsythia", + "fritillary", + "fruit-bat", + "fruit-fly", + "fulmar", + "foxtail", + "francolin", + "frigatebird" + ], + "g": [ + "gannet", + "gar", + "gardenia", + "garlic", + "gazelle", + "gecko", + "gerbil", + "geranium", + "gibbon", + "ginger", + "ginkgo", + "giraffe", + "gnat", + "gnu", + "goat", + "goldfinch", + "goldfish", + "goose", + "gopher", + "gorilla", + "goshawk", + "grape", + "grasshopper", + "grebe", + "greyhound", + "grizzly-bear", + "grouper", + "grouse", + "grub", + "guava", + "guillemot", + "guinea-fowl", + "guinea-pig", + "gull", + "guppy", + "garter-snake", + "gentian", + "gharial", + "gladiolus", + "glowworm", + "goldenrod", + "grackle" + ], + "h": [ + "haddock", + "hagfish", + "halibut", + "hamster", + "hare", + "harrier", + "hawk", + "hawthorn", + "hazel", + "hedgehog", + "hellebore", + "hemlock", + "hen", + "heron", + "herring", + "hibiscus", + "hickory", + "hippopotamus", + "hollyhock", + "holly", + "honeybee", + "honeysuckle", + "hoopoe", + "hornbeam", + "hornet", + "horse", + "horsefly", + "hosta", + "hound", + "hummingbird", + "humpback-whale", + "hyacinth", + "hyena", + "hydrangea", + "hammerhead-shark", + "heath", + "heliotrope", + "hellbender", + "hoatzin", + "horseradish", + "husky" + ], + "i": [ + "ibex", + "ibis", + "ichneumon", + "iguana", + "impala", + "indigo-bunting", + "indri", + "iris", + "ironwood", + "isopod", + "ivory-gull", + "ivy", + "impatiens", + "imperial-moth", + "incense-cedar", + "inchworm", + "inkberry", + "irish-setter", + "ide", + "inca-dove", + "inca-tern", + "indian-paintbrush", + "ixia", + "ixora", + "inkcap", + "ironbark", + "ironweed", + "italian-cypress", + "ibisbill", + "iiwi", + "indigo-snake", + "immortelle" + ], + "j": [ + "jackal", + "jackdaw", + "jackfruit", + "jackrabbit", + "jaguar", + "jasmine", + "jay", + "jellyfish", + "jerboa", + "jonquil", + "juniper", + "junglefowl", + "junco", + "jacamar", + "jacana", + "jacaranda", + "jaeger", + "jaguarundi", + "java-sparrow", + "javelina", + "jerusalem-artichoke", + "jewelweed", + "jicama", + "john-dory", + "jojoba", + "joshua-tree", + "jujube", + "jumping-spider", + "june-beetle", + "jute", + "jacobin-pigeon", + "jewel-beetle", + "judas-tree", + "jird" + ], + "k": [ + "kakapo", + "kale", + "kangaroo", + "katydid", + "kelp", + "kestrel", + "killdeer", + "killer-whale", + "kingfisher", + "kinglet", + "kingsnake", + "kite", + "kitten", + "kiwi", + "koala", + "kohlrabi", + "kookaburra", + "krill", + "kudu", + "kakariki", + "kapok", + "kea", + "kelpie", + "kingbird", + "kinkajou", + "kissing-bug", + "kit-fox", + "knapweed", + "knifefish", + "knotweed", + "koel", + "komodo-dragon", + "kowari", + "krait", + "kumquat", + "king-crab", + "king-penguin" + ], + "l": [ + "labrador", + "lacewing", + "ladybug", + "lamb", + "lamprey", + "langur", + "lantana", + "larch", + "lark", + "larkspur", + "laurel", + "lavender", + "leech", + "lemming", + "lemon", + "lemur", + "leopard", + "lichen", + "lilac", + "lily", + "limpet", + "lingonberry", + "linnet", + "lion", + "lionfish", + "lizard", + "llama", + "lobelia", + "lobster", + "locust", + "longhorn-beetle", + "loon", + "loquat", + "loris", + "lotus", + "lovebird", + "lungfish", + "lupine", + "lynx", + "lapwing", + "leafhopper", + "leatherback" + ], + "m": [ + "macaque", + "macaw", + "mackerel", + "magnolia", + "magpie", + "mahogany", + "mako-shark", + "mallard", + "mamba", + "mammoth", + "manatee", + "mandrill", + "mango", + "mangrove", + "mantis", + "maple", + "marigold", + "marlin", + "marmoset", + "marmot", + "marten", + "martin", + "mastiff", + "mayfly", + "meadowlark", + "meerkat", + "millipede", + "mink", + "minnow", + "mistletoe", + "mockingbird", + "mole", + "mongoose", + "monkey", + "moose", + "moray-eel", + "morning-glory", + "mosquito", + "moth", + "mouse", + "mulberry", + "mule", + "mushroom", + "muskox", + "mussel", + "mynah", + "myrtle" + ], + "n": [ + "narcissus", + "narwhal", + "nasturtium", + "needlefish", + "nematode", + "nene", + "newt", + "nightingale", + "nightjar", + "nightshade", + "nuthatch", + "nutmeg", + "nautilus", + "nettle", + "nighthawk", + "nilgai", + "noddy", + "nutria", + "nyala", + "naked-mole-rat", + "natterjack", + "nectarine", + "neem", + "nerine", + "nicotiana", + "ninebark", + "noni", + "numbat", + "nutcracker", + "nudibranch" + ], + "o": [ + "oak", + "oat", + "ocelot", + "octopus", + "okapi", + "okra", + "oleander", + "olive", + "opossum", + "orangutan", + "orca", + "orchid", + "oregano", + "oriole", + "osprey", + "ostrich", + "otter", + "owl", + "ox", + "oxpecker", + "oyster", + "oystercatcher", + "oarfish", + "ocean-sunfish", + "oilbird", + "onager", + "onion", + "orange", + "orb-weaver", + "oribi", + "ortolan", + "oryx", + "ovenbird", + "oxalis", + "oxeye-daisy" + ], + "p": [ + "palm", + "pansy", + "panther", + "papaya", + "parakeet", + "parrot", + "parsley", + "partridge", + "peach", + "peacock", + "peanut", + "pear", + "pelican", + "penguin", + "peony", + "pepper", + "perch", + "periwinkle", + "petunia", + "pig", + "pigeon", + "pike", + "pine", + "pineapple", + "piranha", + "platypus", + "plover", + "plum", + "polecat", + "pomegranate", + "pony", + "poodle", + "poplar", + "poppy", + "porcupine", + "prawn", + "praying-mantis", + "primrose", + "ptarmigan", + "puffin", + "puma", + "pumpkin", + "python" + ], + "ph": [ + "pheasant", + "phlox", + "phoenix" + ], + "q": [ + "quail", + "quince", + "quokka", + "quoll", + "queen-bee", + "queen-conch", + "queen-snake", + "quaking-aspen", + "quetzal", + "quillwort", + "quahog", + "queensland-heeler", + "quail-dove", + "quillback", + "queen-angelfish", + "queen-butterfly", + "queen-palm" + ], + "r": [ + "rabbit", + "raccoon", + "radish", + "ragweed", + "ram", + "raspberry", + "rat", + "rattlesnake", + "raven", + "ray", + "redbud", + "redstart", + "redwood", + "reed", + "reindeer", + "rhinoceros", + "rhododendron", + "rhubarb", + "robin", + "rockfish", + "roe-deer", + "rook", + "rooster", + "rose", + "rosemary", + "rottweiler", + "rowan", + "ruff", + "ragwort", + "rail", + "rambutan", + "ratel", + "redpoll", + "redwing", + "rhea", + "ringtail", + "roadrunner", + "roller", + "roseate-spoonbill", + "rosella", + "rosewood", + "royal-palm" + ], + "s": [ + "salamander", + "salmon", + "sardine", + "scallop", + "scorpion", + "seahorse", + "seal", + "sequoia", + "skink", + "skunk", + "sloth", + "slug", + "snail", + "snake", + "snapdragon", + "snapper", + "snowdrop", + "sole", + "sparrow", + "spider", + "spinach", + "spruce", + "squid", + "squirrel", + "starfish", + "starling", + "stingray", + "stork", + "strawberry", + "sturgeon", + "sunflower", + "swallow", + "swan", + "swift", + "sycamore", + "sage", + "sandpiper", + "sea-lion", + "sea-urchin", + "snipe", + "sorrel", + "spaniel", + "stoat" + ], + "sh": [ + "shark", + "sheep", + "shrew", + "shrimp" + ], + "t": [ + "tamarin", + "tang", + "tapir", + "tarantula", + "tarpon", + "teak", + "teal", + "termite", + "tern", + "terrier", + "tetra", + "tick", + "tiger", + "titmouse", + "toad", + "tomato", + "toucan", + "tortoise", + "toucanet", + "trout", + "tulip", + "tuna", + "turkey", + "turnip", + "turtle", + "tadpole", + "tahr", + "takin", + "tanager", + "tarsier", + "tasmanian-devil", + "teasel", + "tench", + "terrapin", + "tilapia", + "toadflax", + "tody", + "tragopan", + "treecreeper", + "trumpet-vine", + "tsetse-fly", + "turbot" + ], + "th": [ + "thistle", + "thrasher", + "thrush", + "thyme", + "thornbill" + ], + "u": [ + "uakari", + "unicornfish", + "urchin", + "urial", + "umbrellabird", + "umbrella-pine", + "umbrella-plant", + "unau", + "upas-tree", + "ural-owl", + "umbrella-thorn", + "urd-bean", + "uva-ursi", + "uguisu", + "upupa", + "umbra" + ], + "v": [ + "vampire-bat", + "vanilla", + "veery", + "velvet-ant", + "venus-flytrap", + "verbena", + "vervain", + "vervet-monkey", + "vetch", + "viceroy-butterfly", + "vine", + "viola", + "violet", + "viper", + "vireo", + "viscacha", + "vole", + "vulture", + "valerian", + "vanda-orchid", + "veiltail", + "velvet-worm", + "verdin", + "vicuna", + "vinca", + "viperfish", + "virginia-creeper", + "vizsla", + "volcano-rabbit", + "vampire-squid", + "varied-thrush", + "vermilion-flycatcher", + "vesper-sparrow", + "viburnum", + "vinegaroon" + ], + "w": [ + "wallaby", + "walnut", + "walrus", + "warbler", + "warthog", + "wasp", + "water-buffalo", + "water-lily", + "watermelon", + "weasel", + "weevil", + "willow", + "wolf", + "wolverine", + "wombat", + "woodchuck", + "woodlouse", + "woodpecker", + "worm", + "wren", + "wagtail", + "wahoo", + "wallflower", + "walleye", + "wapiti", + "water-vole", + "wattlebird", + "waxbill", + "waxwing", + "weaverbird", + "weeping-willow", + "wigeon", + "wildebeest", + "wintergreen", + "wireworm", + "wisteria", + "witch-hazel", + "woodcock", + "woodlark", + "wormwood" + ], + "wh": [ + "whale", + "wheat", + "whelk", + "whippet", + "whippoorwill", + "whitebeam", + "whitethroat" + ], + "x": [ + "xylophone", + "xenops", + "xerus", + "xebec", + "xantus-hummingbird", + "xanthium", + "xylophis" + ], + "y": [ + "yak", + "yam", + "yarrow", + "yellowhammer", + "yellowjacket", + "yew", + "yorkshire-terrier", + "yucca", + "yabby", + "yaffle", + "yapok", + "yellow-perch", + "yellow-warbler", + "yellowtail", + "yellowfin-tuna", + "yellowlegs", + "yeti-crab", + "yucca-moth", + "yuzu" + ], + "z": [ + "zander", + "zebra", + "zebra-finch", + "zebrafish", + "zebu", + "zinnia", + "zonkey", + "zorilla", + "zebra-dove", + "zebra-mussel", + "zebra-shark", + "zelkova", + "zigzag-salamander", + "zokor", + "zorse", + "zucchini", + "zebra-spider", + "zebrawood", + "zebra-swallowtail", + "zonure" + ] + } +} diff --git a/src/utils/alliterate.ts b/src/utils/alliterate.ts new file mode 100644 index 0000000000..a94db9399f --- /dev/null +++ b/src/utils/alliterate.ts @@ -0,0 +1,34 @@ +import library from 'src/utils/alliterate-library.json'; + +interface Words { + modifiers: Record; + objects: Record; +} + +const words = library as Words; + +// Letters that have both a modifier and an object, so a pair can be formed. +const LETTERS = Object.keys(words.objects).filter( + (letter) => words.modifiers[letter]?.length && words.objects[letter]?.length +); + +function randomItem(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function pickPair(): [string, string] { + const letter = randomItem(LETTERS); + return [randomItem(words.modifiers[letter]), randomItem(words.objects[letter])]; +} + +// e.g. "frolicking ferret" +export function generatePair(): string { + const [modifier, object] = pickPair(); + return `${modifier} ${object}`; +} + +// A catalog-name-safe alliterative slug, e.g. "frolicking-ferret". +export function generateAlliterativeName(): string { + const [modifier, object] = pickPair(); + return `${modifier}-${object}`; +}