8000 feat: add Organization Provisioner Keys view by johnstcn · Pull Request #17889 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: add Organization Provisioner Keys view #17889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(site): add Organization Provisioner Keys view
  • Loading branch information
johnstcn committed May 19, 2025
commit f30fa7605345ab2d36003aea180f249175fa4238
2 changes: 1 addition & 1 deletion site/src/api/queries/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions site/src/modules/management/OrganizationSidebarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { buildInfo } from "api/queries/buildInfo";
import { provisionerDaemonGroups } from "api/queries/organizations";
import { EmptyState } from "components/EmptyState/EmptyState";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
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 { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const provisionerKeyDaemonsQuery = useQuery({
...provisionerDaemonGroups(organizationName),
});

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}
buildVersion={buildInfoQuery.data?.version}
provisionerKeyDaemons={provisionerKeyDaemonsQuery.data}
error={provisionerKeyDaemonsQuery.error}
onRetry={provisionerKeyDaemonsQuery.refetch}
/>
</>
);
};

export default OrganizationProvisionerKeysPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockProvisioner, MockProvisionerKey } from "testHelpers/entities";
import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView";

const mockProvisionerKeyDaemons = [
{
key: {
...MockProvisionerKey,
},
daemons: [
{
...MockProvisioner,
},
],
},
];

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 Example: Story = {};

export const Paywalled: Story = {
args: {
showPaywall: true,
},
};

export const NoProvisionerKeys: Story = {
args: {
provisionerKeyDaemons: [],
},
};

export const ErrorLoadingProvisionerKeys: Story = {
args: {
error: "Failed to load provisioner keys",
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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";

interface OrganizationProvisionerKeysPageViewProps {
showPaywall: boolean | undefined;
provisionerKeyDaemons: ProvisionerKeyDaemons[] | undefined;
buildVersion: string | undefined;
error: unknown;
onRetry: () => void;
}

export const OrganizationProvisionerKeysPageView: FC<
OrganizationProvisionerKeysPageViewProps
> = ({ showPaywall, provisionerKeyDaemons, buildVersion, 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."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not just multi-org right? Keys themselves require license too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentationLink={docs("/")}
/>
) : (
<Table className="mt-6">
<TableHeader>
<TableRow>
<TableHead>Created</TableHead>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Provisioners</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) => {
return (
pkd.key.id !== ProvisionerKeyIDBuiltIn &&
pkd.key.id !== ProvisionerKeyIDUserAuth &&
pkd.key.id !== ProvisionerKeyIDPSK
);
})
.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>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { ProvisionerDaemon, ProvisionerKey } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { CopyButton } from "components/CopyButton/CopyButton";
import { TableCell, TableRow } from "components/Table/Table";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { ProvisionerTag } 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>
<span className="block first-letter:uppercase">
{relativeTime(new Date(provisionerKey.created_at))}
</span>
</Button>
</TableCell>
<TableCell>{provisionerKey.name}</TableCell>
<TableCell>
<span className="font-mono text-content-primary">
{provisionerKey.id}
</span>
<CopyButton text={provisionerKey.id} label="Copy ID" />
</TableCell>
<TableCell>
{Object.entries(provisionerKey.tags).map(([k, v]) => (
<span key={k}>
<ProvisionerTag label={k} value={v} />
</span>
))}
</TableCell>
<TableCell>{provisioners.length}</TableCell>
</TableRow>

{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
{provisioners.length === 0 ? (
<span className="text-muted-foreground">
No provisioners found for this key.
</span>
) : (
<dl>
<dt>Provisioners:</dt>
{provisioners.map((provisioner) => (
<dd key={provisioner.id}>
<span className="font-mono text-content-primary">
{provisioner.name} ({provisioner.id}){" "}
</span>
<CopyButton
text={provisioner.id}
label="Copy provisioner ID"
/>
<Button size="xs" variant="outline" asChild>
<RouterLink
to={`../provisioners?${new URLSearchParams({ ids: provisioner.id })}`}
>
View provisioner
</RouterLink>
</Button>
</dd>
))}
</dl>
)}
</TableCell>
</TableRow>
)}
</>
);
};
10 changes: 10 additions & 0 deletions site/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
0