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(); }; diff --git a/src/api/gql/serviceAccounts.ts b/src/api/gql/serviceAccounts.ts new file mode 100644 index 0000000000..820a73302c --- /dev/null +++ b/src/api/gql/serviceAccounts.ts @@ -0,0 +1,202 @@ +import type { ServiceAccount } from 'src/gql-types/graphql'; + +import { useCallback, 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 { + catalogName + createdAt + createdBy + updatedAt + lastUsedAt + grants { + prefix + capability + createdAt + detail + updatedAt + } + tokens { + id + detail + createdAt + createdBy + expiresAt + lastUsedAt + } + } + cursor + } + pageInfo { + ...PageInfoFields + } + } + } +`); + +export function useServiceAccounts(afterCursor?: string) { + const [{ fetching, data, error }] = 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, + }; +} + +// 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(` + mutation CreateServiceAccount( + $catalogName: Name! + $grants: [ServiceAccountGrantInput!]! + ) { + createServiceAccount(catalogName: $catalogName, grants: $grants) { + catalogName + createdAt + createdBy + } + } +`); + +// 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! + ) { + createServiceAccountToken( + catalogName: $catalogName + detail: $detail + validFor: $validFor + ) { + id + secret + } + } +`); + +const REVOKE_SERVICE_ACCOUNT_TOKEN = graphql(` + mutation RevokeServiceAccountToken($id: Id!) { + revokeServiceAccountToken(id: $id) + } +`); + +// 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) + } +`); + +// 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); +} + +export function useCreateServiceAccountToken() { + return useMutation(CREATE_SERVICE_ACCOUNT_TOKEN); +} + +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); +} + +export function useRevokeAllServiceAccountTokens() { + return useMutation(REVOKE_ALL_SERVICE_ACCOUNT_TOKENS); +} diff --git a/src/app/routes.ts b/src/app/routes.ts index 930d42f76e..6cfa9ca948 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -31,6 +31,16 @@ const admin = { fullPath: '/admin/billing/paymentMethod/new', }, }, + serviceAccounts: { + 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', path: 'settings', @@ -154,6 +164,15 @@ const express = { }, }; +const flowctl = { + accessToken: { + title: 'routeTitle.flowctl.accessToken', + path: 'accessToken', + fullPath: '/flowctl/accessToken', + }, + path: 'flowctl', +}; + const home = { title: 'routeTitle.home', path: '/welcome', @@ -235,6 +254,16 @@ const pageNotFound = { path: '*', }; +const settings = { + title: 'routeTitle.settings', + path: 'settings', + personalTokens: { + title: 'routeTitle.settings.personalTokens', + path: 'personalTokens', + fullPath: '/settings/personalTokens', + }, +}; + const user = { title: 'routeTitle.user', path: 'user', @@ -264,11 +293,13 @@ export const authenticatedRoutes = { collections, dataPlaneAuth, express, + flowctl, home, materializations, marketplace: marketplace.authenticated, user, pageNotFound, + settings, beta, }; diff --git a/src/components/admin/Api/AccessToken.tsx b/src/components/admin/Api/AccessToken.tsx deleted file mode 100644 index 682bd917d6..0000000000 --- a/src/components/admin/Api/AccessToken.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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'; - -function AccessToken() { - const session = useUserStore((state) => state.session); - - return ( - - - - - - - - - - - - - - - - {/* TODO (defect): Display an error in the event the access token does not exist. */} - - - ); -} - -export default AccessToken; diff --git a/src/components/admin/Api/RefreshToken/CreateDialog.tsx b/src/components/admin/Api/RefreshToken/CreateDialog.tsx index 7f02ed03c4..8bebf4e07b 100644 --- a/src/components/admin/Api/RefreshToken/CreateDialog.tsx +++ b/src/components/admin/Api/RefreshToken/CreateDialog.tsx @@ -15,7 +15,7 @@ import { } from '@mui/material'; import { Xmark } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { useIntl } from 'react-intl'; import { useCreateRefreshToken } from 'src/api/gql/refreshTokens'; import SingleLineCode from 'src/components/content/SingleLineCode'; @@ -25,12 +25,14 @@ import { hasLength } from 'src/utils/misc-utils'; const TOKEN_VALIDITY = 'P1Y'; -// The shared Error component treats an error's `message` as an i18n key unless -// the object looks like a Supabase or GraphQL error (it carries a `code` or a -// `networkError`). This client-side error carries neither, so the message is -// resolved from the language files. +// The shared Error component renders `message` verbatim when the error object +// carries a `code` or `networkError`; otherwise it treats the message as an +// i18n key. This client-side error sets a sentinel code so its inlined copy is +// shown directly. const TOKEN_DISPLAY_ERROR: ErrorDetails = { - message: 'admin.cli_api.refreshToken.dialog.alert.tokenEncodingFailed', + message: + 'An issue was encountered displaying your token. Please generate a new token.', + code: 'token_display_failed', }; interface Props { @@ -98,9 +100,7 @@ export function CreateRefreshTokenDialog({ open, onClose, onCreated }: Props) { onClose={token || generating ? undefined : onClose} maxWidth="sm" fullWidth - aria-label={intl.formatMessage({ - id: 'admin.cli_api.refreshToken.dialog.header', - })} + aria-label="Create Personal Token" slotProps={{ transition: { onExited: resetDialog, @@ -115,9 +115,7 @@ export function CreateRefreshTokenDialog({ open, onClose, onCreated }: Props) { justifyContent: 'space-between', }} > - - - + Create Personal Token - + Copy this personal token now - you won’t be able + to see it again! @@ -153,9 +152,7 @@ export function CreateRefreshTokenDialog({ open, onClose, onCreated }: Props) { sx={{ pt: 1 }} > setLabel(event.target.value) @@ -178,7 +175,7 @@ export function CreateRefreshTokenDialog({ open, onClose, onCreated }: Props) { type="submit" variant="contained" > - + Create )} 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/Api/index.tsx b/src/components/admin/Api/index.tsx deleted file mode 100644 index 9e9e93fe6c..0000000000 --- a/src/components/admin/Api/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Divider, Grid, Typography } from '@mui/material'; - -import { FormattedMessage } from 'react-intl'; - -import { authenticatedRoutes } from 'src/app/routes'; -import AccessToken from 'src/components/admin/Api/AccessToken'; -import { RefreshToken } from 'src/components/admin/Api/RefreshToken'; -import AdminTabs from 'src/components/admin/Tabs'; -import usePageTitle from 'src/hooks/usePageTitle'; - -function AdminApi() { - usePageTitle({ - header: authenticatedRoutes.admin.api.title, - }); - - return ( - <> - - - - - - - - - - - - - - - - - - - - - ); -} - -export default AdminApi; diff --git a/src/components/admin/ServiceAccounts/AccountCard.tsx b/src/components/admin/ServiceAccounts/AccountCard.tsx new file mode 100644 index 0000000000..8500acf888 --- /dev/null +++ b/src/components/admin/ServiceAccounts/AccountCard.tsx @@ -0,0 +1,271 @@ +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'; + +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': (theme) => theme.radius.lg, + '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.radius.md, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 14, + fontWeight: 700, + color: '#06121f', + background: hasGrants + ? `linear-gradient(135deg, ${logoColors.purple}, ${logoColors.teal})` + : (theme) => + 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/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..44be60bdaa --- /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': (theme) => theme.radius.lg, + '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.radius.sm, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 12, + fontWeight: 700, + color: 'text.secondary', + background: (theme) => + 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 new file mode 100644 index 0000000000..e75d42cb0b --- /dev/null +++ b/src/components/admin/ServiceAccounts/CreateApiKeyDialog.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { useCreateServiceAccountToken } from 'src/api/gql/serviceAccounts'; +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 TITLE_ID = 'create-service-account-api-key'; + +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({ + open, + catalogName, + onClose, + onCreated, +}: CreateApiKeyDialogProps) { + const [label, setLabel] = useState(''); + const [validFor, setValidFor] = useState(DEFAULT_LIFETIME); + const [secret, setSecret] = useState(null); + const [error, setError] = useState(null); + + const [{ fetching }, createServiceAccountToken] = + useCreateServiceAccountToken(); + + useEffect(() => { + if (open) { + setLabel(''); + setValidFor(DEFAULT_LIFETIME); + setSecret(null); + setError(null); + } + }, [open]); + + const handleCreate = async () => { + setError(null); + + if (!hasLength(label)) { + return; + } + + const result = await createServiceAccountToken({ + catalogName, + detail: label, + validFor, + }); + + if (result.error || !result.data?.createServiceAccountToken) { + setError( + result.error?.message ?? + 'There was an error creating the API key.' + ); + return; + } + + setSecret(result.data.createServiceAccountToken.secret); + }; + + const handleRevealDone = () => { + setSecret(null); + onCreated?.(); + onClose(); + }; + + return ( + <> + + + Create API key + + + + + + {catalogName} + + + {error ? ( + + {error} + + ) : null} + + setLabel(event.target.value)} + required + size="small" + fullWidth + placeholder="CI deploy pipeline" + helperText="Helps you recognise this key later." + /> + + + + Lifetime + + + + + + + + + + + + + + + ); +} + +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..68c5731904 --- /dev/null +++ b/src/components/admin/ServiceAccounts/CreateDialog.tsx @@ -0,0 +1,628 @@ +import type { Capability } from 'src/types'; +import type { SxProps, Theme } from '@mui/material'; + +import { useEffect, useRef, useState } from 'react'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + FormControlLabel, + IconButton, + InputAdornment, + Stack, + Step, + StepLabel, + Stepper, + Switch, + TextField, + Tooltip, + Typography, +} from '@mui/material'; + +import { NavArrowLeft, Plus, Refresh, Trash } from 'iconoir-react'; + +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 DialogTitleWithClose from 'src/components/shared/Dialog/TitleWithClose'; +import { LeavesAutocomplete } from 'src/components/shared/LeavesAutocomplete/LeavesAutocomplete'; +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'; +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: (theme) => theme.radius.sm, + bgcolor: (theme) => codeBackground[theme.palette.mode], +}; + +export function CreateServiceAccountDialog({ + open, + mode, + onClose, + onCreated, +}: CreateServiceAccountDialogProps) { + const { leaves, selectedTenant } = usePrefixLeaves(); + + const [{ fetching: creatingAccount }, createServiceAccount] = + useCreateServiceAccount(); + const [{ fetching: creatingToken }, createServiceAccountToken] = + useCreateServiceAccountToken(); + const fetching = creatingAccount || creatingToken; + + 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); + 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(generateAlliterativeName()); + 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 addGuidedGrant = () => { + setGuidedGrants((prev) => [ + ...prev, + { prefix: selectedTenant, capability: 'read' }, + ]); + }; + + const removeGuidedGrant = (index: number) => { + setGuidedGrants((prev) => prev.filter((_, i) => i !== index)); + }; + + const finishCreated = (createdCatalogName: string) => { + onClose(); + onCreated?.(createdCatalogName); + }; + + const handleRevealDone = () => { + const name_ = createdName; + setReveal(null); + setCreatedName(null); + onClose(); + if (name_) { + onCreated?.(name_); + } + }; + + const handleSubmit = async () => { + setError(null); + + if (!identityComplete) { + return; + } + + const grants = + localMode === 'quick' + ? grantOn + ? [{ prefix: location, capability: quickCapability }] + : [] + : guidedGrants.filter((grant) => hasLength(grant.prefix)); + + const result = await createServiceAccount({ catalogName, grants }); + + if (result.error) { + setError(result.error.message); + return; + } + + 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() + ) + } + inputRef={nameInputRef} + autoFocus + size="small" + fullWidth + required + placeholder="banana-bot" + helperText="Lowercase letters, numbers and dashes. Must be unique." + slotProps={{ + input: { + endAdornment: ( + + + + + + + + ), + }, + }} + /> + ); + + const locationField = ( + + ); + + const fullNamePreview = ( + + + Full name + + + {namePreview} + + + ); + + return ( + <> + nameInputRef.current?.select(), + }, + }} + > + + Create service account + + A non-login identity for programmatic access. + + + + + + { + if (next) { + setLocalMode(next); + setStep(1); + } + }} + > + + Quick setup + + + Guided + + + + {error ? ( + + {error} + + ) : null} + + {localMode === 'quick' ? ( + <> + {nameField} + {locationField} + {fullNamePreview} + + theme.radius.md, + border: (theme) => + defaultOutline[theme.palette.mode], + }} + > + + setGrantOn( + event.target.checked + ) + } + /> + } + label="Grant access to this prefix" + /> + + {grantOn ? ( + <> + + + {location} + + + + + Quick setup grants the same + prefix the account lives under. + Need different or multiple + prefixes? Switch to Guided, or + add grants later from the + account. + + + ) : null} + + + ) : ( + <> + + {GUIDED_STEPS.map((label) => ( + + {label} + + ))} + + + {step === 1 ? ( + <> + {nameField} + {locationField} + {fullNamePreview} + + ) : null} + + {step === 2 ? ( + <> + + Grant this account access to one or + more catalog prefixes. You can change + these anytime. + + + {guidedGrants.map((grant, index) => ( + + + + updateGuidedGrant( + index, + { + prefix: value, + } + ) + } + label="Catalog prefix" + /> + + + updateGuidedGrant( + index, + { capability } + ) + } + /> + + removeGuidedGrant(index) + } + > + + + + ))} + + + + ) : 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} + + )} + + + + + {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..2b0d58653d --- /dev/null +++ b/src/components/admin/ServiceAccounts/Details/GrantsSection.tsx @@ -0,0 +1,339 @@ +import type { ServiceAccountGrant } from 'src/gql-types/graphql'; +import type { Capability } from 'src/types'; + +import { useState } from 'react'; + +import { + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + FormControlLabel, + IconButton, + Stack, + Typography, +} from '@mui/material'; + +import { EditPencil, Folder, Lock, Plus, Trash } from 'iconoir-react'; + +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'; +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; +} + +interface GrantDialogState { + mode: 'add' | 'edit'; + prefix?: string; + capability?: Capability; +} + +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) { + return; + } + + setRemoveError(null); + + const result = await removeServiceAccountGrant({ + catalogName, + prefix: removeTarget.prefix, + }); + + if (result.error) { + setRemoveError(result.error.message); + 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(); + }; + + 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.prefix} + + + + setDialog({ + mode: 'edit', + prefix: grant.prefix, + capability: grant.capability as Capability, + }) + } + > + + + openRemove(grant)} + > + + + + )) + )} + + setDialog(null)} + onSaved={onChanged} + /> + + setRemoveTarget(null)} + maxWidth="xs" + fullWidth + > + + + theme.radius.md, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + bgcolor: (theme) => theme.palette.error.alpha_12, + color: 'error.main', + }} + > + + + + + + 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}`, + }} + > + + 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. + + } + /> + + ) : null} + + {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..8fdb2279cf --- /dev/null +++ b/src/components/admin/ServiceAccounts/Details/index.tsx @@ -0,0 +1,227 @@ +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 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 [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 = ( + <> + + theme.radius.md, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 17, + fontWeight: 700, + color: '#06121f', + background: `linear-gradient(135deg, ${logoColors.purple}, ${logoColors.teal})`, + }} + > + {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..2ce47f1cc7 --- /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 ( + + theme.radius.xl, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mb: 3, + border: (theme) => 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..57757f3ea4 --- /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 + + theme.radius.sm, + color: 'text.secondary', + bgcolor: (theme) => + 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..fb8ee82391 --- /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..efc746f7e8 --- /dev/null +++ b/src/components/admin/ServiceAccounts/List.tsx @@ -0,0 +1,206 @@ +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 { 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 [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; + + // Accounts without any grants are de-emphasized and grouped at the bottom. + const grantedAccounts = serviceAccounts.filter( + (account) => account.grants.length > 0 + ); + const noAccessAccounts = serviceAccounts.filter( + (account) => account.grants.length === 0 + ); + + 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/SecretRevealModal.tsx b/src/components/admin/ServiceAccounts/SecretRevealModal.tsx new file mode 100644 index 0000000000..b62a0f5ad2 --- /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.radius.md, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + bgcolor: (theme) => + 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/index.tsx b/src/components/admin/ServiceAccounts/index.tsx new file mode 100644 index 0000000000..be5ea246ff --- /dev/null +++ b/src/components/admin/ServiceAccounts/index.tsx @@ -0,0 +1,22 @@ +import { Box } from '@mui/material'; + +import { authenticatedRoutes } from 'src/app/routes'; +import { ServiceAccountsList } from 'src/components/admin/ServiceAccounts/List'; +import AdminTabs from 'src/components/admin/Tabs'; +import usePageTitle from 'src/hooks/usePageTitle'; + +export function ServiceAccounts() { + usePageTitle({ + header: authenticatedRoutes.admin.serviceAccounts.title, + }); + + return ( + <> + + + + + + + ); +} diff --git a/src/components/admin/ServiceAccounts/shared.ts b/src/components/admin/ServiceAccounts/shared.ts new file mode 100644 index 0000000000..65f5230408 --- /dev/null +++ b/src/components/admin/ServiceAccounts/shared.ts @@ -0,0 +1,80 @@ +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). 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: string): 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/components/admin/Tabs.tsx b/src/components/admin/Tabs.tsx index ed07f90d03..ba467873b7 100644 --- a/src/components/admin/Tabs.tsx +++ b/src/components/admin/Tabs.tsx @@ -30,8 +30,8 @@ function AdminTabs() { } response.push({ - labelMessageId: 'admin.tabs.api', - path: authenticatedRoutes.admin.api.fullPath, + labelMessageId: 'admin.tabs.serviceAccounts', + path: authenticatedRoutes.admin.serviceAccounts.fullPath, }); return response; 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 dfc5c94fc7..b00639edbd 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,8 +12,8 @@ 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'; import { ErrorImporting } from 'src/components/shared/ErrorImporting'; import HasSupportRoleGuard from 'src/components/shared/guards/SupportRole'; @@ -33,11 +34,13 @@ 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'; 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'; @@ -86,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( @@ -233,6 +239,18 @@ const router = createBrowserRouter( element={} /> + } + /> + + } + /> + } @@ -687,10 +705,11 @@ const router = createBrowserRouter( /> - - + loader={() => + redirect( + authenticatedRoutes.flowctl.accessToken + .fullPath + ) } /> @@ -731,6 +750,28 @@ const router = createBrowserRouter( } /> + + + + } + /> + + + + + + } + /> null, PrefixRef: (_data) => null, RefreshTokenInfo: (_data) => null, + ServiceAccount: (_data) => null, + ServiceAccountTokenInfo: (_data) => null, StorageMapping: (data) => null, DataPlane: (data) => null, }, @@ -87,6 +89,15 @@ function UrqlConfigProvider({ children }: BaseComponentProps) { revokeRefreshToken(_result, _args, cache) { invalidateQuery(cache, 'refreshTokens'); }, + createServiceAccount(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, + createServiceAccountToken(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, + revokeServiceAccountToken(_result, _args, cache) { + invalidateQuery(cache, 'serviceAccounts'); + }, }, }, }), diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index f3bf218a46..a68b27c3c4 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -30,6 +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 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, @@ -58,6 +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 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, @@ -148,6 +162,34 @@ 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 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. + */ +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. + */ +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. + */ +export function graphql(source: "\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 documents)["\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"]; +/** + * 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 231e807659..ffcbe9aa75 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -362,6 +362,7 @@ export type CapabilityBit = | 'DeleteGrant' | 'JournalAppend' | 'JournalRead' + | 'ManageServiceAccount' | 'ModifyDataPlanePrivateNetworking' | 'SpecEdit' | 'ViewDataPlanePrivateNetworking'; @@ -560,6 +561,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 +1027,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. @@ -1031,6 +1053,34 @@ export type MutationRoot = { createInviteLink: InviteLink; /** Create a refresh token for the authenticated user. */ createRefreshToken: RefreshTokenResult; + /** + * 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; /** * Create a storage mapping for the given catalog prefix. * @@ -1055,6 +1105,39 @@ export type MutationRoot = { * catalog prefix with the specified capability. */ redeemInviteLink: 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: Scalars['Int']['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 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: Scalars['Int']['output']; /** * Revoke a refresh token owned by the authenticated user. * @@ -1063,6 +1146,18 @@ export type MutationRoot = { * 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']; setBillingPaymentMethod: BillingPaymentMethodPayload; /** * Check storage health for a given catalog prefix and storage definition. @@ -1123,6 +1218,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 +1253,19 @@ export type MutationRootCreateRefreshTokenArgs = { }; +export type MutationRootCreateServiceAccountArgs = { + catalogName: Scalars['Name']['input']; + grants: Array; +}; + + +export type MutationRootCreateServiceAccountTokenArgs = { + catalogName: Scalars['Name']['input']; + detail: Scalars['String']['input']; + validFor: Scalars['String']['input']; +}; + + export type MutationRootCreateStorageMappingArgs = { catalogPrefix: Scalars['Prefix']['input']; detail?: InputMaybe; @@ -1180,11 +1295,32 @@ export type MutationRootRedeemInviteLinkArgs = { }; +export type MutationRootRemoveAllServiceAccountGrantsArgs = { + catalogName: Scalars['Name']['input']; +}; + + +export type MutationRootRemoveServiceAccountGrantArgs = { + catalogName: Scalars['Name']['input']; + prefix: Scalars['Prefix']['input']; +}; + + +export type MutationRootRevokeAllServiceAccountTokensArgs = { + catalogName: Scalars['Name']['input']; +}; + + export type MutationRootRevokeRefreshTokenArgs = { id: Scalars['Id']['input']; }; +export type MutationRootRevokeServiceAccountTokenArgs = { + id: Scalars['Id']['input']; +}; + + export type MutationRootSetBillingPaymentMethodArgs = { paymentMethodId: Scalars['String']['input']; tenant: Scalars['String']['input']; @@ -1454,6 +1590,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. * @@ -1543,6 +1680,12 @@ export type QueryRootRefreshTokensArgs = { }; +export type QueryRootServiceAccountsArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + + export type QueryRootStorageMappingsArgs = { after?: InputMaybe; before?: InputMaybe; @@ -1618,6 +1761,69 @@ export type RepublishRequested = { receivedAt: Scalars['DateTime']['output']; }; +export type ServiceAccount = { + __typename?: 'ServiceAccount'; + catalogName: Scalars['Name']['output']; + createdAt: Scalars['DateTime']['output']; + createdBy: Scalars['UUID']['output']; + grants: Array; + 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 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; + 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; +}; + /** The shape of a connector status, which matches that of an ops::Log. */ export type ShardFailure = { __typename?: 'ShardFailure'; @@ -2049,6 +2255,62 @@ 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, 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']; + 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 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 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']; @@ -2139,6 +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":"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/gql-types/schema.graphql b/src/gql-types/schema.graphql index 66fbf3c76e..52ee5a12de 100644 --- a/src/gql-types/schema.graphql +++ b/src/gql-types/schema.graphql @@ -347,6 +347,7 @@ enum CapabilityBit { DeleteGrant JournalAppend JournalRead + ManageServiceAccount ModifyDataPlanePrivateNetworking SpecEdit ViewDataPlanePrivateNetworking @@ -581,6 +582,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 +1049,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. @@ -1064,6 +1087,42 @@ type MutationRoot { validFor: String! = "P90D" ): RefreshTokenResult! + """ + 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! + """ Create a storage mapping for the given catalog prefix. @@ -1094,6 +1153,42 @@ 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. + + 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 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. @@ -1102,6 +1197,19 @@ type MutationRoot { 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! setBillingPaymentMethod(paymentMethodId: String!, tenant: String!): BillingPaymentMethodPayload! """ @@ -1446,6 +1554,7 @@ type QueryRoot { """List refresh tokens owned by the authenticated user.""" refreshTokens(after: String, first: Int): RefreshTokenInfoConnection! + serviceAccounts(after: String, first: Int): ServiceAccountConnection! """ Returns storage mappings accessible to the current user. @@ -1521,6 +1630,66 @@ type RepublishRequested { receivedAt: DateTime! } +type ServiceAccount { + catalogName: Name! + createdAt: DateTime! + createdBy: UUID! + grants: [ServiceAccountGrant!]! + 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 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! + 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 +} + """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..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.`, @@ -145,7 +126,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/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 64c4469df1..d2644b365c 100644 --- a/src/lang/en-US/RouteTitles.ts +++ b/src/lang/en-US/RouteTitles.ts @@ -7,6 +7,8 @@ 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.serviceAccounts.details': `Service Account`, 'routeTitle.admin.settings': `Settings`, 'routeTitle.captureCreate': `Create Capture`, 'routeTitle.captureDetails': `Capture Details`, @@ -20,6 +22,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`, @@ -28,6 +31,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/FlowctlAccessToken.tsx b/src/pages/FlowctlAccessToken.tsx new file mode 100644 index 0000000000..e0bd6a2733 --- /dev/null +++ b/src/pages/FlowctlAccessToken.tsx @@ -0,0 +1,77 @@ +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 ( + + + {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; 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; 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}`; +}