8000 feat(site): add Organization Provisioner Keys view · coder/coder@f30fa76 · GitHub
[go: up one dir, main page]

Skip to content

Commit f30fa76

Browse files
committed
feat(site): add Organization Provisioner Keys view
1 parent 3dbd424 commit f30fa76

File tree

8 files changed

+349
-2
lines changed

8 files changed

+349
-2
lines changed

site/src/api/queries/organizations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ const getProvisionerDaemonGroupsKey = (organization: string) => [
187187
"provisionerDaemons",
188188
];
189189

190-
const provisionerDaemonGroups = (organization: string) => {
190+
export const provisionerDaemonGroups = (organization: string) => {
191191
return {
192192
queryKey: getProvisionerDaemonGroupsKey(organization),
193193
queryFn: () => API.getProvisionerDaemonGroupsByOrganization(organization),

site/src/modules/management/OrganizationSidebarView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ const OrganizationSettingsNavigation: FC<
190190
>
191191
Provisioners
192192
</SettingsSidebarNavItem>
193+
<SettingsSidebarNavItem
194+
href={urlForSubpage(organization.name, "provisioner-keys")}
195+
>
196+
Provisioner Keys
197+
</SettingsSidebarNavItem>
193198
<SettingsSidebarNavItem
194199
href={urlForSubpage(organization.name, "provisioner-jobs")}
195200
>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { buildInfo } from "api/queries/buildInfo";
2+
import { provisionerDaemonGroups } from "api/queries/organizations";
3+
import { EmptyState } from "components/EmptyState/EmptyState";
4+
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
5+
import { useDashboard } from "modules/dashboard/useDashboard";
6+
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
7+
import { RequirePermission } from "modules/permissions/RequirePermission";
8+
import type { FC } from "react";
9+
import { Helmet } from "react-helmet-async";
10+
import { useQuery } from "react-query";
11+
import { useParams } from "react-router-dom";
12+
import { pageTitle } from "utils/page";
13+
import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView";
14+
15+
const OrganizationProvisionerKeysPage: FC = () => {
16+
const { organization: organizationName } = useParams() as {
17+
organization: string;
18+
};
19+
const { organization, organizationPermissions } = useOrganizationSettings();
20+
const { entitlements } = useDashboard();
21+
const { metadata } = useEmbeddedMetadata();
22+
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
23+
const provisionerKeyDaemonsQuery = useQuery({
24+
...provisionerDaemonGroups(organizationName),
25+
});
26+
27+
if (!organization) {
28+
return <EmptyState message="Organization not found" />;
29+
}
30+
31+
const helmet = (
32+
<Helmet>
33+
<title>
34+
{pageTitle(
35+
"Provisioner Keys",
36+
organization.display_name || organization.name,
37+
)}
38+
</title>
39+
</Helmet>
40+
);
41+
42+
if (!organizationPermissions?.viewProvisioners) {
43+
return (
44+
<>
45+
{helmet}
46+
<RequirePermission isFeatureVisible={false} />
47+
</>
48+
);
49+
}
50+
51+
return (
52+
<>
53+
{helmet}
54+
<OrganizationProvisionerKeysPageView
55+
showPaywall={!entitlements.features.multiple_organizations.enabled}
56+
buildVersion={buildInfoQuery.data?.version}
57+
provisionerKeyDaemons={provisionerKeyDaemonsQuery.data}
58+
error={provisionerKeyDaemonsQuery.error}
59+
onRetry={provisionerKeyDaemonsQuery.refetch}
60+
/>
61+
</>
62+
);
63+
};
64+
65+
export default OrganizationProvisionerKeysPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { MockProvisioner, MockProvisionerKey } from "testHelpers/entities";
3+
import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView";
4+
5+
const mockProvisionerKeyDaemons = [
6+
{
7+
key: {
8+
...MockProvisionerKey,
9+
},
10+
daemons: [
11+
{
12+
...MockProvisioner,
13+
},
14+
],
15+
},
16+
];
17+
18+
const meta: Meta<typeof OrganizationProvisionerKeysPageView> = {
19+
title: "pages/OrganizationProvisionerKeysPage",
20+
component: OrganizationProvisionerKeysPageView,
21+
args: {
22+
error: undefined,
23+
provisionerKeyDaemons: mockProvisionerKeyDaemons,
24+
onRetry: () => {},
25+
},
26+
};
27+
28+
export default meta;
29+
type Story = StoryObj<typeof OrganizationProvisionerKeysPageView>;
30+
31+
export const Example: Story = {};
32+
33+
export const Paywalled: Story = {
34+
args: {
35+
showPaywall: true,
36+
},
37+
};
38+
39+
export const NoProvisionerKeys: Story = {
40+
args: {
41+
provisionerKeyDaemons: [],
42+
},
43+
};
44+
45+
export const ErrorLoadingProvisionerKeys: Story = {
46+
args: {
47+
error: "Failed to load provisioner keys",
48+
},
49+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
type ProvisionerKeyDaemons,
3+
ProvisionerKeyIDBuiltIn,
4+
ProvisionerKeyIDPSK,
5+
ProvisionerKeyIDUserAuth,
6+
} from "api/typesGenerated";
7+
import { Button } from "components/Button/Button";
8+
import { EmptyState } from "components/EmptyState/EmptyState";
9+
import { Link } from "components/Link/Link";
10+
import { Loader } from "components/Loader/Loader";
11+
import { Paywall } from "components/Paywall/Paywall";
12+
import {
13+
SettingsHeader,
14+
SettingsHeaderDescription,
15+
SettingsHeaderTitle,
16+
} from "components/SettingsHeader/SettingsHeader";
17+
import {
18+
Table,
19+
TableBody,
20+
TableCell,
21+
TableHead,
22+
TableHeader,
23+
TableRow,
24+
} from "components/Table/Table";
25+
import type { FC } from "react";
26+
import { docs } from "utils/docs";
27+
import { ProvisionerKeyRow } from "./ProvisionerKeyRow";
28+
29+
interface OrganizationProvisionerKeysPageViewProps {
30+
showPaywall: boolean | undefined;
31+
provisionerKeyDaemons: ProvisionerKeyDaemons[] | undefined;
32+
buildVersion: string | undefined;
33+
error: unknown;
34+
onRetry: () => void;
35+
}
36+
37+
export const OrganizationProvisionerKeysPageView: FC<
38+
OrganizationProvisionerKeysPageViewProps
39+
> = ({ showPaywall, provisionerKeyDaemons, buildVersion, error, onRetry }) => {
40+
return (
41+
<section>
42+
<SettingsHeader>
43+
<SettingsHeaderTitle>Provisioner Keys</SettingsHeaderTitle>
44+
<SettingsHeaderDescription>
45+
Manage provisioner keys used to authenticate provisioner instances.{" "}
46+
<Link href={docs("/admin/provisioners")}>View docs</Link>
47+
</SettingsHeaderDescription>
48+
</SettingsHeader>
49+
50+
{showPaywall ? (
51+
<Paywall
52+
message="Provisioners"
53+
description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations."
54+
documentationLink={docs("/")}
55+
/>
56+
) : (
57+
<Table className="mt-6">
58+
<TableHeader>
59+
<TableRow>
60+
<TableHead>Created</TableHead>
61+
<TableHead>Name</TableHead>
62+
<TableHead>ID</TableHead>
63+
<TableHead>Tags</TableHead>
64+
<TableHead>Provisioners</TableHead>
65+
</TableRow>
66+
</TableHeader>
67+
<TableBody>
68+
{provisionerKeyDaemons ? (
69+
provisionerKeyDaemons.length === 0 ? (
70+
<TableRow>
71+
<TableCell colSpan={5}>
72+
<EmptyState
73+
message="No provisioner keys"
74+
description="Create your first provisioner key to authenticate external provisioner daemons."
75+
/>
76+
</TableCell>
77+
</TableRow>
78+
) : (
79+
provisionerKeyDaemons
80+
.filter((pkd) => {
81+
return (
82+
pkd.key.id !== ProvisionerKeyIDBuiltIn &&
83+
pkd.key.id !== ProvisionerKeyIDUserAuth &&
84+
pkd.key.id !== ProvisionerKeyIDPSK
85+
);
86+
})
87+
.map((pkd) => (
88+
<ProvisionerKeyRow
89+
key={pkd.key.id}
90+
provisionerKey={pkd.key}
91+
provisioners={pkd.daemons}
92+
defaultIsOpen={false}
93+
/>
94+
))
95+
)
96+
) : error ? (
97+
<TableRow>
98+
<TableCell colSpan={5}>
99+
<EmptyState
100+
message="Error loading provisioner keys"
101+
cta={
102+
<Button onClick={onRetry} size="sm">
103+
Retry
104+
</Button>
105+
}
106+
/>
107+
</TableCell>
108+
</TableRow>
109+
) : (
110+
<TableRow>
111+
<TableCell colSpan={999}>
112+
<Loader />
113+
</TableCell>
114+
</TableRow>
115+
)}
116+
</TableBody>
117+
</Table>
118+
)}
119+
</section>
120+
);
121+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { ProvisionerDaemon, ProvisionerKey } from "api/typesGenerated";
2+
import { Button } from "components/Button/Button";
3+
import { CopyButton } from "components/CopyButton/CopyButton";
4+
import { TableCell, TableRow } from "components/Table/Table";
5+
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
6+
import { ProvisionerTag } from "modules/provisioners/ProvisionerTags";
7+
import { type FC, useState } from "react";
8+
import { Link as RouterLink } from "react-router-dom";
9+
import { cn } from "utils/cn";
10+
import { relativeTime } from "utils/time";
11+
12+
type ProvisionerKeyRowProps = {
13+
readonly provisionerKey: ProvisionerKey;
14+
readonly provisioners: readonly ProvisionerDaemon[];
15+
defaultIsOpen: boolean;
16+
};
17+
18+
export const ProvisionerKeyRow: FC<ProvisionerKeyRowProps> = ({
19+
provisionerKey,
20+
provisioners,
21+
defaultIsOpen = false,
22+
}) => {
23+
const [isOpen, setIsOpen] = useState(defaultIsOpen);
24+
25+
return (
26+
<>
27+
<TableRow key={provisionerKey.id}>
28+
<TableCell>
29+
<Button
30+
variant="subtle"
31+
size="sm"
32+
className={cn([
33+
isOpen && "text-content-primary",
34+
"p-0 h-auto min-w-0 align-middle",
35+
])}
36+
onClick={() => setIsOpen((v) => !v)}
37+
>
38+
{isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
39+
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
40+
<span className="block first-letter:uppercase">
41+
{relativeTime(new Date(provisionerKey.created_at))}
42+
</span>
43+
</Button>
44+
</TableCell>
45+
<TableCell>{provisionerKey.name}</TableCell>
46+
<TableCell>
47+
<span className="font-mono text-content-primary">
48+
{provisionerKey.id}
49+
</span>
50+
<CopyButton text={provisionerKey.id} label="Copy ID" />
51+
</TableCell>
52+
<TableCell>
53+
{Object.entries(provisionerKey.tags).map(([k, v]) => (
54+
<span key={k}>
55+
<ProvisionerTag label={k} value={v} />
56+
</span>
57+
))}
58+
</TableCell>
59+
<TableCell>{provisioners.length}</TableCell>
60+
</TableRow>
61+
62+
{isOpen && (
63+
<TableRow>
64+
<TableCell colSpan={999} className="p-4 border-t-0">
65+
{provisioners.length === 0 ? (
66+
<span className="text-muted-foreground">
67+
No provisioners found for this key.
68+
</span>
69+
) : (
70+
<dl>
71+
<dt>Provisioners:</dt>
72+
{provisioners.map((provisioner) => (
73+
<dd key={provisioner.id}>
74+
<span className="font-mono text-content-primary">
75+
{provisioner.name} ({provisioner.id}){" "}
76+< D7AE div class="diff-text-inner"> </span>
77+
<CopyButton
78+
text={provisioner.id}
79+
label="Copy provisioner ID"
80+
/>
81+
<Button size="xs" variant="outline" asChild>
82+
<RouterLink
83+
to={`../provisioners?${new URLSearchParams({ ids: provisioner.id })}`}
84+
>
85+
View provisioner
86+
</RouterLink>
87+
</Button>
88+
</dd>
89+
))}
90+
</dl>
91+
)}
92+
</TableCell>
93+
</TableRow>
94+
)}
95+
</>
96+
);
97+
};

site/src/router.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@ const ChangePasswordPage = lazy(
313313
const IdpOrgSyncPage = lazy(
314314
() => import("./pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage"),
315315
);
316+
const ProvisionerKeysPage = lazy(
317+
() =>
318+
import(
319+
"./pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage"
320+
),
321+
);
316322
const ProvisionerJobsPage = lazy(
317323
() =>
318324
import(
@@ -449,6 +455,10 @@ export const router = createBrowserRouter(
449455
path="provisioner-jobs"
450456
element={<ProvisionerJobsPage />}
451457
/>
458+
<Route
459+
path="provisioner-keys"
460+
element={<ProvisionerKeysPage />}
461+
/>
452462
<Route path="idp-sync" element={<OrganizationIdPSyncPage />} />
453463
<Route path="settings" element={<OrganizationSettingsPage />} />
454464
</Route>

site/src/testHelpers/entities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData =
561561
roles: [],
562562
};
563563

564-
const MockProvisionerKey: TypesGen.ProvisionerKey = {
564+
export const MockProvisionerKey: TypesGen.ProvisionerKey = {
565565
id: "test-provisioner-key",
566566
organization: MockOrganization.id,
567567
created_at: "2022-05-17T17:39:01.382927298Z",

0 commit comments

Comments
 (0)
0