diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index c7b42f5f0e79f..608b2fa2a1ac4 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -187,7 +187,7 @@ const getProvisionerDaemonGroupsKey = (organization: string) => [ "provisionerDaemons", ]; -const provisionerDaemonGroups = (organization: string) => { +export const provisionerDaemonGroups = (organization: string) => { return { queryKey: getProvisionerDaemonGroupsKey(organization), queryFn: () => API.getProvisionerDaemonGroupsByOrganization(organization), diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index e6b23b8a4dd94..b4d405055bb98 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -9,7 +9,6 @@ import { cn } from "utils/cn"; const badgeVariants = cva( `inline-flex items-center rounded-md border px-2 py-1 transition-colors - focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:pr-0.5 [&_svg]:py-0.5 [&_svg]:mr-0.5`, { variants: { @@ -30,11 +29,23 @@ const badgeVariants = cva( none: "border-transparent", solid: "border border-solid", }, + hover: { + false: null, + true: "no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link", + }, }, + compoundVariants: [ + { + hover: true, + variant: "default", + class: "hover:bg-surface-tertiary", + }, + ], defaultVariants: { variant: "default", size: "md", border: "solid", + hover: false, }, }, ); @@ -46,14 +57,20 @@ export interface BadgeProps } export const Badge = forwardRef( - ({ className, variant, size, border, asChild = false, ...props }, ref) => { + ( + { className, variant, size, border, hover, asChild = false, ...props }, + ref, + ) => { const Comp = asChild ? Slot : "div"; return ( ); }, diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index a03dc62b65c0e..745268278da49 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -190,6 +190,11 @@ const OrganizationSettingsNavigation: FC< > Provisioners + + Provisioner Keys + diff --git a/site/src/modules/provisioners/ProvisionerTags.tsx b/site/src/modules/provisioners/ProvisionerTags.tsx index b31be42df234f..667d2cb56ef15 100644 --- a/site/src/modules/provisioners/ProvisionerTags.tsx +++ b/site/src/modules/provisioners/ProvisionerTags.tsx @@ -9,7 +9,7 @@ export const ProvisionerTags: FC> = ({ return (
); }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx new file mode 100644 index 0000000000000..77bcfe10cb229 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx @@ -0,0 +1,62 @@ +import { provisionerDaemonGroups } from "api/queries/organizations"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; +import { RequirePermission } from "modules/permissions/RequirePermission"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import { pageTitle } from "utils/page"; +import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView"; + +const OrganizationProvisionerKeysPage: FC = () => { + const { organization: organizationName } = useParams() as { + organization: string; + }; + const { organization, organizationPermissions } = useOrganizationSettings(); + const { entitlements } = useDashboard(); + const provisionerKeyDaemonsQuery = useQuery({ + ...provisionerDaemonGroups(organizationName), + select: (data) => + [...data].sort((a, b) => b.daemons.length - a.daemons.length), + }); + + if (!organization) { + return ; + } + + const helmet = ( + + + {pageTitle( + "Provisioner Keys", + organization.display_name || organization.name, + )} + + + ); + + if (!organizationPermissions?.viewProvisioners) { + return ( + <> + {helmet} + + + ); + } + + return ( + <> + {helmet} + + + ); +}; + +export default OrganizationProvisionerKeysPage; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx new file mode 100644 index 0000000000000..f30ea66175e07 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type ProvisionerKeyDaemons, + ProvisionerKeyIDBuiltIn, + ProvisionerKeyIDPSK, + ProvisionerKeyIDUserAuth, +} from "api/typesGenerated"; +import { + MockProvisioner, + MockProvisionerKey, + mockApiError, +} from "testHelpers/entities"; +import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView"; + +const mockProvisionerKeyDaemons: ProvisionerKeyDaemons[] = [ + { + key: { + ...MockProvisionerKey, + }, + daemons: [ + { + ...MockProvisioner, + name: "Test Provisioner 1", + id: "daemon-1", + }, + { + ...MockProvisioner, + name: "Test Provisioner 2", + id: "daemon-2", + }, + ], + }, + { + key: { + ...MockProvisionerKey, + name: "no-daemons", + }, + daemons: [], + }, + // Built-in provisioners, user-auth, and PSK keys are not shown here. + { + key: { + ...MockProvisionerKey, + id: ProvisionerKeyIDBuiltIn, + name: "built-in", + }, + daemons: [], + }, + { + key: { + ...MockProvisionerKey, + id: ProvisionerKeyIDUserAuth, + name: "user-auth", + }, + daemons: [], + }, + { + key: { + ...MockProvisionerKey, + id: ProvisionerKeyIDPSK, + name: "PSK", + }, + daemons: [], + }, +]; + +const meta: Meta = { + title: "pages/OrganizationProvisionerKeysPage", + component: OrganizationProvisionerKeysPageView, + args: { + error: undefined, + provisionerKeyDaemons: mockProvisionerKeyDaemons, + onRetry: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + error: undefined, + provisionerKeyDaemons: mockProvisionerKeyDaemons, + onRetry: () => {}, + showPaywall: false, + }, +}; + +export const Paywalled: Story = { + ...Default, + args: { + showPaywall: true, + }, +}; + +export const Empty: Story = { + ...Default, + args: { + provisionerKeyDaemons: [], + }, +}; + +export const WithError: Story = { + ...Default, + args: { + provisionerKeyDaemons: undefined, + error: mockApiError({ + message: "Error loading provisioner keys", + detail: "Something went wrong. This is an unhelpful error message.", + }), + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx new file mode 100644 index 0000000000000..5373636308f15 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx @@ -0,0 +1,123 @@ +import { + type ProvisionerKeyDaemons, + ProvisionerKeyIDBuiltIn, + ProvisionerKeyIDPSK, + ProvisionerKeyIDUserAuth, +} from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Link } from "components/Link/Link"; +import { Loader } from "components/Loader/Loader"; +import { Paywall } from "components/Paywall/Paywall"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "components/SettingsHeader/SettingsHeader"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import type { FC } from "react"; +import { docs } from "utils/docs"; +import { ProvisionerKeyRow } from "./ProvisionerKeyRow"; + +// If the user using provisioner keys for external provisioners you're unlikely to +// want to keep the built-in provisioners. +const HIDDEN_PROVISIONER_KEYS = [ + ProvisionerKeyIDBuiltIn, + ProvisionerKeyIDUserAuth, + ProvisionerKeyIDPSK, +]; + +interface OrganizationProvisionerKeysPageViewProps { + showPaywall: boolean | undefined; + provisionerKeyDaemons: ProvisionerKeyDaemons[] | undefined; + error: unknown; + onRetry: () => void; +} + +export const OrganizationProvisionerKeysPageView: FC< + OrganizationProvisionerKeysPageViewProps +> = ({ showPaywall, provisionerKeyDaemons, error, onRetry }) => { + return ( +
+ + Provisioner Keys + + Manage provisioner keys used to authenticate provisioner instances.{" "} + View docs + + + + {showPaywall ? ( + + ) : ( + + + + Name + Tags + Provisioners + Created + + + + {provisionerKeyDaemons ? ( + provisionerKeyDaemons.length === 0 ? ( + + + + + + ) : ( + provisionerKeyDaemons + .filter( + (pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id), + ) + .map((pkd) => ( + + )) + ) + ) : error ? ( + + + + Retry + + } + /> + + + ) : ( + + + + + + )} + +
+ )} +
+ ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/ProvisionerKeyRow.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/ProvisionerKeyRow.tsx new file mode 100644 index 0000000000000..e1b337c85dacb --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/ProvisionerKeyRow.tsx @@ -0,0 +1,136 @@ +import type { ProvisionerDaemon, ProvisionerKey } from "api/typesGenerated"; +import { Badge } from "components/Badge/Badge"; +import { Button } from "components/Button/Button"; +import { TableCell, TableRow } from "components/Table/Table"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { + ProvisionerTag, + ProvisionerTags, + ProvisionerTruncateTags, +} from "modules/provisioners/ProvisionerTags"; +import { type FC, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { cn } from "utils/cn"; +import { relativeTime } from "utils/time"; + +type ProvisionerKeyRowProps = { + readonly provisionerKey: ProvisionerKey; + readonly provisioners: readonly ProvisionerDaemon[]; + defaultIsOpen: boolean; +}; + +export const ProvisionerKeyRow: FC = ({ + provisionerKey, + provisioners, + defaultIsOpen = false, +}) => { + const [isOpen, setIsOpen] = useState(defaultIsOpen); + + return ( + <> + + + + + + {Object.entries(provisionerKey.tags).length > 0 ? ( + + ) : ( + No tags + )} + + + {provisioners.length > 0 ? ( + + ) : ( + No provisioners + )} + + + + {relativeTime(new Date(provisionerKey.created_at))} + + + + + {isOpen && ( + + +
+
Creation time:
+
{provisionerKey.created_at}
+ +
Tags:
+
+ + {Object.entries(provisionerKey.tags).length === 0 && ( + No tags + )} + {Object.entries(provisionerKey.tags).map(([key, value]) => ( + + ))} + +
+ +
Provisioners:
+
+ + {provisioners.length === 0 && ( + + No provisioners + + )} + {provisioners.map((provisioner) => ( + + + {provisionerKey.name} + + + ))} + +
+
+
+
+ )} + + ); +}; + +type TruncateProvisionersProps = { + provisioners: readonly ProvisionerDaemon[]; +}; + +const TruncateProvisioners: FC = ({ + provisioners, +}) => { + const firstProvisioner = provisioners[0]; + const remainderCount = provisioners.length - 1; + + return ( + + {firstProvisioner.name} + {remainderCount > 0 && +{remainderCount}} + + ); +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 534d4037d02b3..5784696a16f2d 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -313,6 +313,12 @@ const ChangePasswordPage = lazy( const IdpOrgSyncPage = lazy( () => import("./pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage"), ); +const ProvisionerKeysPage = lazy( + () => + import( + "./pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage" + ), +); const ProvisionerJobsPage = lazy( () => import( @@ -449,6 +455,10 @@ export const router = createBrowserRouter( path="provisioner-jobs" element={} /> + } + /> } /> } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6351e74d3c54d..e09b196a82446 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -561,7 +561,7 @@ export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = roles: [], }; -const MockProvisionerKey: TypesGen.ProvisionerKey = { +export const MockProvisionerKey: TypesGen.ProvisionerKey = { id: "test-provisioner-key", organization: MockOrganization.id, created_at: "2022-05-17T17:39:01.382927298Z",