feat: add Organization Provisioner Keys view (#17889)

Fixes https://github.com/coder/coder/issues/17698

**Demo:**


https://github.com/user-attachments/assets/ba92693f-29b7-43ee-8d69-3d77214f3230

---------

Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
This commit is contained in:
Cian Johnston
2025-05-19 16:58:12 +01:00
committed by GitHub
parent 61f22a59ba
commit ac7961a5b0
10 changed files with 471 additions and 6 deletions

View File

@ -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),

View File

@ -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<HTMLDivElement, BadgeProps>(
({ className, variant, size, border, asChild = false, ...props }, ref) => {
(
{ className, variant, size, border, hover, asChild = false, ...props },
ref,
) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
{...props}
ref={ref}
className={cn(badgeVariants({ variant, size, border }), className)}
className={cn(
badgeVariants({ variant, size, border, hover }),
className,
)}
/>
);
},

View File

@ -190,6 +190,11 @@ const OrganizationSettingsNavigation: FC<
>
Provisioners
</SettingsSidebarNavItem>
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "provisioner-keys")}
>
Provisioner Keys
</SettingsSidebarNavItem>
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "provisioner-jobs")}
>

View File

@ -9,7 +9,7 @@ export const ProvisionerTags: FC<HTMLProps<HTMLDivElement>> = ({
return (
<div
{...props}
className={cn(["flex items-center gap-1 flex-wrap", className])}
className={cn(["flex items-center gap-1 flex-wrap py-0.5", className])}
/>
);
};

View File

@ -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 <EmptyState message="Organization not found" />;
}
const helmet = (
<Helmet>
<title>
{pageTitle(
"Provisioner Keys",
organization.display_name || organization.name,
)}
</title>
</Helmet>
);
if (!organizationPermissions?.viewProvisioners) {
return (
<>
{helmet}
<RequirePermission isFeatureVisible={false} />
</>
);
}
return (
<>
{helmet}
<OrganizationProvisionerKeysPageView
showPaywall={!entitlements.features.multiple_organizations.enabled}
provisionerKeyDaemons={provisionerKeyDaemonsQuery.data}
error={provisionerKeyDaemonsQuery.error}
onRetry={provisionerKeyDaemonsQuery.refetch}
/>
</>
);
};
export default OrganizationProvisionerKeysPage;

View File

@ -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<typeof OrganizationProvisionerKeysPageView> = {
title: "pages/OrganizationProvisionerKeysPage",
component: OrganizationProvisionerKeysPageView,
args: {
error: undefined,
provisionerKeyDaemons: mockProvisionerKeyDaemons,
onRetry: () => {},
},
};
export default meta;
type Story = StoryObj<typeof OrganizationProvisionerKeysPageView>;
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.",
}),
},
};

View File

@ -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 (
<section>
<SettingsHeader>
<SettingsHeaderTitle>Provisioner Keys</SettingsHeaderTitle>
<SettingsHeaderDescription>
Manage provisioner keys used to authenticate provisioner instances.{" "}
<Link href={docs("/admin/provisioners")}>View docs</Link>
</SettingsHeaderDescription>
</SettingsHeader>
{showPaywall ? (
<Paywall
message="Provisioners"
description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations."
documentationLink={docs("/")}
/>
) : (
<Table className="mt-6">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Provisioners</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{provisionerKeyDaemons ? (
provisionerKeyDaemons.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<EmptyState
message="No provisioner keys"
description="Create your first provisioner key to authenticate external provisioner daemons."
/>
</TableCell>
</TableRow>
) : (
provisionerKeyDaemons
.filter(
(pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id),
)
.map((pkd) => (
<ProvisionerKeyRow
key={pkd.key.id}
provisionerKey={pkd.key}
provisioners={pkd.daemons}
defaultIsOpen={false}
/>
))
)
) : error ? (
<TableRow>
<TableCell colSpan={5}>
<EmptyState
message="Error loading provisioner keys"
cta={
<Button onClick={onRetry} size="sm">
Retry
</Button>
}
/>
</TableCell>
</TableRow>
) : (
<TableRow>
<TableCell colSpan={999}>
<Loader />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</section>
);
};

View File

@ -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<ProvisionerKeyRowProps> = ({
provisionerKey,
provisioners,
defaultIsOpen = false,
}) => {
const [isOpen, setIsOpen] = useState(defaultIsOpen);
return (
<>
<TableRow key={provisionerKey.id}>
<TableCell>
<Button
variant="subtle"
size="sm"
className={cn([
isOpen && "text-content-primary",
"p-0 h-auto min-w-0 align-middle",
])}
onClick={() => setIsOpen((v) => !v)}
>
{isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
{provisionerKey.name}
</Button>
</TableCell>
<TableCell>
{Object.entries(provisionerKey.tags).length > 0 ? (
<ProvisionerTruncateTags tags={provisionerKey.tags} />
) : (
<span className="text-content-disabled">No tags</span>
)}
</TableCell>
<TableCell>
{provisioners.length > 0 ? (
<TruncateProvisioners provisioners={provisioners} />
) : (
<span className="text-content-disabled">No provisioners</span>
)}
</TableCell>
<TableCell>
<span className="block first-letter:uppercase">
{relativeTime(new Date(provisionerKey.created_at))}
</span>
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
<dl
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
])}
>
<dt>Creation time:</dt>
<dd data-chromatic="ignore">{provisionerKey.created_at}</dd>
<dt>Tags:</dt>
<dd>
<ProvisionerTags>
{Object.entries(provisionerKey.tags).length === 0 && (
<span className="text-content-disabled">No tags</span>
)}
{Object.entries(provisionerKey.tags).map(([key, value]) => (
<ProvisionerTag key={key} label={key} value={value} />
))}
</ProvisionerTags>
</dd>
<dt>Provisioners:</dt>
<dd>
<ProvisionerTags>
{provisioners.length === 0 && (
<span className="text-content-disabled">
No provisioners
</span>
)}
{provisioners.map((provisioner) => (
<Badge hover key={provisioner.id} size="sm" asChild>
<RouterLink
to={`../provisioners?${new URLSearchParams({ ids: provisioner.id })}`}
>
{provisionerKey.name}
</RouterLink>
</Badge>
))}
</ProvisionerTags>
</dd>
</dl>
</TableCell>
</TableRow>
)}
</>
);
};
type TruncateProvisionersProps = {
provisioners: readonly ProvisionerDaemon[];
};
const TruncateProvisioners: FC<TruncateProvisionersProps> = ({
provisioners,
}) => {
const firstProvisioner = provisioners[0];
const remainderCount = provisioners.length - 1;
return (
<ProvisionerTags>
<Badge size="sm">{firstProvisioner.name}</Badge>
{remainderCount > 0 && <Badge size="sm">+{remainderCount}</Badge>}
</ProvisionerTags>
);
};

View File

@ -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={<ProvisionerJobsPage />}
/>
<Route
path="provisioner-keys"
element={<ProvisionerKeysPage />}
/>
<Route path="idp-sync" element={<OrganizationIdPSyncPage />} />
<Route path="settings" element={<OrganizationSettingsPage />} />
</Route>

View File

@ -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",