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+
</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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy