Skip to content

Commit ac7961a

Browse files
feat: add Organization Provisioner Keys view (#17889)
Fixes #17698 **Demo:** https://github.com/user-attachments/assets/ba92693f-29b7-43ee-8d69-3d77214f3230 --------- Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
1 parent 61f22a5 commit ac7961a

File tree

10 files changed

+471
-6
lines changed

10 files changed

+471
-6
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/components/Badge/Badge.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { cn } from "utils/cn";
99

1010
const badgeVariants = cva(
1111
`inline-flex items-center rounded-md border px-2 py-1 transition-colors
12-
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2
1312
[&_svg]:pointer-events-none [&_svg]:pr-0.5 [&_svg]:py-0.5 [&_svg]:mr-0.5`,
1413
{
1514
variants: {
@@ -30,11 +29,23 @@ const badgeVariants = cva(
3029
none: "border-transparent",
3130
solid: "border border-solid",
3231
},
32+
hover: {
33+
false: null,
34+
true: "no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
35+
},
3336
},
37+
compoundVariants: [
38+
{
39+
hover: true,
40+
variant: "default",
41+
class: "hover:bg-surface-tertiary",
42+
},
43+
],
3444
defaultVariants: {
3545
variant: "default",
3646
size: "md",
3747
border: "solid",
48+
hover: false,
3849
},
3950
},
4051
);
@@ -46,14 +57,20 @@ export interface BadgeProps
4657
}
4758

4859
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
49-
({ className, variant, size, border, asChild = false, ...props }, ref) => {
60+
(
61+
{ className, variant, size, border, hover, asChild = false, ...props },
62+
ref,
63+
) => {
5064
const Comp = asChild ? Slot : "div";
5165

5266
return (
5367
<Comp
5468
{...props}
5569
ref={ref}
56-
className={cn(badgeVariants({ variant, size, border }), className)}
70+
className={cn(
71+
badgeVariants({ variant, size, border, hover }),
72+
className,
73+
)}
5774
/>
5875
);
5976
},

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
>

site/src/modules/provisioners/ProvisionerTags.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const ProvisionerTags: FC<HTMLProps<HTMLDivElement>> = ({
99
return (
1010
<div
1111
{...props}
12-
className={cn(["flex items-center gap-1 flex-wrap", className])}
12+
className={cn(["flex items-center gap-1 flex-wrap py-0.5", className])}
1313
/>
1414
);
1515
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { provisionerDaemonGroups } from "api/queries/organizations";
2+
import { EmptyState } from "components/EmptyState/EmptyState";
3+
import { useDashboard } from "modules/dashboard/useDashboard";
4+
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
5+
import { RequirePermission } from "modules/permissions/RequirePermission";
6+
import type { FC } from "react";
7+
import { Helmet } from "react-helmet-async";
8+
import { useQuery } from "react-query";
9+
import { useParams } from "react-router-dom";
10+
import { pageTitle } from "utils/page";
11+
import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView";
12+
13+
const OrganizationProvisionerKeysPage: FC = () => {
14+
const { organization: organizationName } = useParams() as {
15+
organization: string;
16+
};
17+
const { organization, organizationPermissions } = useOrganizationSettings();
18+
const { entitlements } = useDashboard();
19+
const provisionerKeyDaemonsQuery = useQuery({
20+
...provisionerDaemonGroups(organizationName),
21+
select: (data) =>
22+
[...data].sort((a, b) => b.daemons.length - a.daemons.length),
23+
});
24+
25+
if (!organization) {
26+
return <EmptyState message="Organization not found" />;
27+
}
28+
29+
const helmet = (
30+
<Helmet>
31+
<title>
32+
{pageTitle(
33+
"Provisioner Keys",
34+
organization.display_name || organization.name,
35+
)}
36+
</title>
37+
</Helmet>
38+
);
39+
40+
if (!organizationPermissions?.viewProvisioners) {
41+
return (
42+
<>
43+
{helmet}
44+
<RequirePermission isFeatureVisible={false} />
45+
</>
46+
);
47+
}
48+
49+
return (
50+
<>
51+
{helmet}
52+
<OrganizationProvisionerKeysPageView
53+
showPaywall={!entitlements.features.multiple_organizations.enabled}
54+
provisionerKeyDaemons={provisionerKeyDaemonsQuery.data}
55+
error={provisionerKeyDaemonsQuery.error}
56+
onRetry={provisionerKeyDaemonsQuery.refetch}
57+
/>
58+
</>
59+
);
60+
};
61+
62+
export default OrganizationProvisionerKeysPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import {
3+
type ProvisionerKeyDaemons,
4+
ProvisionerKeyIDBuiltIn,
5+
ProvisionerKeyIDPSK,
6+
ProvisionerKeyIDUserAuth,
7+
} from "api/typesGenerated";
8+
import {
9+
MockProvisioner,
10+
MockProvisionerKey,
11+
mockApiError,
12+
} from "testHelpers/entities";
13+
import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView";
14+
15+
const mockProvisionerKeyDaemons: ProvisionerKeyDaemons[] = [
16+
{
17+
key: {
18+
...MockProvisionerKey,
19+
},
20+
daemons: [
21+
{
22+
...MockProvisioner,
23+
name: "Test Provisioner 1",
24+
id: "daemon-1",
25+
},
26+
{
27+
...MockProvisioner,
28+
name: "Test Provisioner 2",
29+
id: "daemon-2",
30+
},
31+
],
32+
},
33+
{
34+
key: {
35+
...MockProvisionerKey,
36+
name: "no-daemons",
37+
},
38+
daemons: [],
39+
},
40+
// Built-in provisioners, user-auth, and PSK keys are not shown here.
41+
{
42+
key: {
43+
...MockProvisionerKey,
44+
id: ProvisionerKeyIDBuiltIn,
45+
name: "built-in",
46+
},
47+
daemons: [],
48+
},
49+
{
50+
key: {
51+
...MockProvisionerKey,
52+
id: ProvisionerKeyIDUserAuth,
53+
name: "user-auth",
54+
},
55+
daemons: [],
56+
},
57+
{
58+
key: {
59+
...MockProvisionerKey,
60+
id: ProvisionerKeyIDPSK,
61+
name: "PSK",
62+
},
63+
daemons: [],
64+
},
65+
];
66+
67+
const meta: Meta<typeof OrganizationProvisionerKeysPageView> = {
68+
title: "pages/OrganizationProvisionerKeysPage",
69+
component: OrganizationProvisionerKeysPageView,
70+
args: {
71+
error: undefined,
72+
provisionerKeyDaemons: mockProvisionerKeyDaemons,
73+
onRetry: () => {},
74+
},
75+
};
76+
77+
export default meta;
78+
type Story = StoryObj<typeof OrganizationProvisionerKeysPageView>;
79+
80+
export const Default: Story = {
81+
args: {
82+
error: undefined,
83+
provisionerKeyDaemons: mockProvisionerKeyDaemons,
84+
onRetry: () => {},
85+
showPaywall: false,
86+
},
87+
};
88+
89+
export const Paywalled: Story = {
90+
...Default,
91+
args: {
92+
showPaywall: true,
93+
},
94+
};
95+
96+
export const Empty: Story = {
97+
...Default,
98+
args: {
99+
provisionerKeyDaemons: [],
100+
},
101+
};
102+
103+
export const WithError: Story = {
104+
...Default,
105+
args: {
106+
provisionerKeyDaemons: undefined,
107+
error: mockApiError({
108+
message: "Error loading provisioner keys",
109+
detail: "Something went wrong. This is an unhelpful error message.",
110+
}),
111+
},
112+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
// If the user using provisioner keys for external provisioners you're unlikely to
30+
// want to keep the built-in provisioners.
31+
const HIDDEN_PROVISIONER_KEYS = [
32+
ProvisionerKeyIDBuiltIn,
33+
ProvisionerKeyIDUserAuth,
34+
ProvisionerKeyIDPSK,
35+
];
36+
37+
interface OrganizationProvisionerKeysPageViewProps {
38+
showPaywall: boolean | undefined;
39+
provisionerKeyDaemons: ProvisionerKeyDaemons[] | undefined;
40+
error: unknown;
41+
onRetry: () => void;
42+
}
43+
44+
export const OrganizationProvisionerKeysPageView: FC<
45+
OrganizationProvisionerKeysPageViewProps
46+
> = ({ showPaywall, provisionerKeyDaemons, error, onRetry }) => {
47+
return (
48+
<section>
49+
<SettingsHeader>
50+
<SettingsHeaderTitle>Provisioner Keys</SettingsHeaderTitle>
51+
<SettingsHeaderDescription>
52+
Manage provisioner keys used to authenticate provisioner instances.{" "}
53+
<Link href={docs("/admin/provisioners")}>View docs</Link>
54+
</SettingsHeaderDescription>
55+
</SettingsHeader>
56+
57+
{showPaywall ? (
58+
<Paywall
59+
message="Provisioners"
60+
description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations."
61+
documentationLink={docs("/")}
62+
/>
63+
) : (
64+
<Table className="mt-6">
65+
<TableHeader>
66+
<TableRow>
67+
<TableHead>Name</TableHead>
68+
<TableHead>Tags</TableHead>
69+
<TableHead>Provisioners</TableHead>
70+
<TableHead>Created</TableHead>
71+
</TableRow>
72+
</TableHeader>
73+
<TableBody>
74+
{provisionerKeyDaemons ? (
75+
provisionerKeyDaemons.length === 0 ? (
76+
<TableRow>
77+
<TableCell colSpan={5}>
78+
<EmptyState
79+
message="No provisioner keys"
80+
description="Create your first provisioner key to authenticate external provisioner daemons."
81+
/>
82+
</TableCell>
83+
</TableRow>
84+
) : (
85+
provisionerKeyDaemons
86+
.filter(
87+
(pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id),
88+
)
89+
.map((pkd) => (
90+
<ProvisionerKeyRow
91+
key={pkd.key.id}
92+
provisionerKey={pkd.key}
93+
provisioners={pkd.daemons}
94+
defaultIsOpen={false}
95+
/>
96+
))
97+
)
98+
) : error ? (
99+
<TableRow>
100+
<TableCell colSpan={5}>
101+
<EmptyState
102+
message="Error loading provisioner keys"
103+
cta={
104+
<Button onClick={onRetry} size="sm">
105+
Retry
106+
</Button>
107+
}
108+
/>
109+
</TableCell>
110+
</TableRow>
111+
) : (
112+
<TableRow>
113+
<TableCell colSpan={999}>
114+
<Loader />
115+
</TableCell>
116+
</TableRow>
117+
)}
118+
</TableBody>
119+
</Table>
120+
)}
121+
</section>
122+
);
123+
};

0 commit comments

Comments
 (0)