mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
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:
@ -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),
|
||||
|
@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
@ -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")}
|
||||
>
|
||||
|
@ -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])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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.",
|
||||
}),
|
||||
},
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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",
|
||||
|
Reference in New Issue
Block a user