feat(ui): inline EKS cluster IAM role creation in wizard (#379)

* feat(ui): inline EKS cluster IAM role creation in wizard

* feat(ui): show trust policy + attached managed policy in EKS role dialog

Render the trust policy with PolicyDocumentViewer (pretty-printed, safe
fallback) instead of a raw read-only textarea, matching the IAM detail
pages. Surface the attached AmazonEKSClusterPolicy ARN so users can see
exactly which permissions the cluster role grants.
This commit is contained in:
Josh Siviour
2026-06-18 14:01:48 +10:00
committed by GitHub
parent 833456ae09
commit 3e6dfde7dd
8 changed files with 217 additions and 28 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -34,6 +34,7 @@ import {
EKS_SUPPORTED_VERSIONS,
} from "@/types/eks"
import { CreateClusterRoleDialog } from "./create-cluster-role-dialog"
import { EksSystemImageRequired } from "./eks-system-image-required"
function vpcLabel(vpc: Vpc): string {
@@ -51,6 +52,7 @@ export function CreateClusterPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [isRechecking, setIsRechecking] = useState(false)
const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false)
const { data: vpcsData } = useSuspenseQuery(ec2VpcsQueryOptions)
const { data: subnetsData } = useSuspenseQuery(ec2SubnetsQueryOptions)
const { data: rolesData } = useSuspenseQuery(iamRolesQueryOptions)
@@ -133,6 +135,15 @@ export function CreateClusterPage() {
{ shouldValidate: true },
)
const clusterName = watch("name")
const handleRoleCreated = async (roleArn: string) => {
await queryClient.invalidateQueries({
queryKey: iamRolesQueryOptions.queryKey,
})
setValue("roleArn", roleArn, { shouldValidate: true })
}
const onSubmit = async (data: CreateClusterFormData) => {
await createCluster.mutateAsync(data)
await navigate({
@@ -209,31 +220,47 @@ export function CreateClusterPage() {
<FieldTitle>
<label htmlFor="cluster-role">Cluster IAM role</label>
</FieldTitle>
<Controller
control={control}
name="roleArn"
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger
aria-invalid={!!errors.roleArn}
className="w-full"
id="cluster-role"
>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.Arn} value={role.Arn ?? ""}>
{role.RoleName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<div className="flex items-center gap-2">
<Controller
control={control}
name="roleArn"
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger
aria-invalid={!!errors.roleArn}
className="w-full"
id="cluster-role"
>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.Arn} value={role.Arn ?? ""}>
{role.RoleName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<Button
onClick={() => setIsRoleDialogOpen(true)}
type="button"
variant="outline"
>
Create role
</Button>
</div>
<FieldError errors={[errors.roleArn]} />
</Field>
<CreateClusterRoleDialog
clusterName={clusterName}
onCreated={handleRoleCreated}
onOpenChange={setIsRoleDialogOpen}
open={isRoleDialogOpen}
/>
<Field>
<FieldTitle>
<label htmlFor="cluster-vpc">VPC</label>
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Field, FieldTitle } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useAttachRolePolicy, useCreateRole } from "@/mutations/iam"
import { PolicyDocumentViewer } from "@/routes/_auth/iam/-components/policy-document-viewer"
import {
AMAZON_EKS_CLUSTER_POLICY_ARN,
EKS_CLUSTER_ASSUME_ROLE_POLICY_DOCUMENT,
} from "@/types/eks"
interface CreateClusterRoleDialogProps {
clusterName: string
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (roleArn: string) => void
}
function defaultRoleName(clusterName: string): string {
const base = clusterName.trim() || "cluster"
return `${base}-eks-cluster-role`
}
export function CreateClusterRoleDialog({
clusterName,
open,
onOpenChange,
onCreated,
}: CreateClusterRoleDialogProps) {
const createRole = useCreateRole()
const attachPolicy = useAttachRolePolicy()
const [roleName, setRoleName] = useState(defaultRoleName(clusterName))
const [formError, setFormError] = useState<string | null>(null)
useEffect(() => {
if (open) {
setRoleName(defaultRoleName(clusterName))
setFormError(null)
}
}, [open, clusterName])
const isPending = createRole.isPending || attachPolicy.isPending
function handleClose(nextOpen: boolean) {
if (!nextOpen) {
setFormError(null)
}
onOpenChange(nextOpen)
}
async function handleCreate() {
setFormError(null)
const name = roleName.trim()
try {
const result = await createRole.mutateAsync({
roleName: name,
assumeRolePolicyDocument: EKS_CLUSTER_ASSUME_ROLE_POLICY_DOCUMENT,
})
await attachPolicy.mutateAsync({
roleName: name,
policyArn: AMAZON_EKS_CLUSTER_POLICY_ARN,
})
const arn = result.Role?.Arn
if (!arn) {
setFormError("Role was created but no ARN was returned.")
return
}
onCreated(arn)
onOpenChange(false)
} catch (error) {
setFormError(
error instanceof Error ? error.message : "Failed to create role.",
)
}
}
return (
<AlertDialog onOpenChange={handleClose} open={open}>
<AlertDialogContent className="max-w-lg sm:max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>Create cluster IAM role</AlertDialogTitle>
<AlertDialogDescription>
Creates an IAM role trusted by the EKS control plane and attaches
the AmazonEKSClusterPolicy managed policy.
</AlertDialogDescription>
</AlertDialogHeader>
<Field>
<FieldTitle>
<label htmlFor="eks-role-name">Role name</label>
</FieldTitle>
<Input
id="eks-role-name"
onChange={(e) => setRoleName(e.target.value)}
placeholder="my-cluster-eks-cluster-role"
value={roleName}
/>
</Field>
<Field>
<FieldTitle>Trust policy (JSON)</FieldTitle>
<PolicyDocumentViewer
document={EKS_CLUSTER_ASSUME_ROLE_POLICY_DOCUMENT}
/>
</Field>
<Field>
<FieldTitle>Attached managed policy</FieldTitle>
<code className="block rounded-md border bg-muted p-2 font-mono text-sm break-all">
{AMAZON_EKS_CLUSTER_POLICY_ARN}
</code>
<p className="text-sm text-muted-foreground">
Grants the EKS control plane the permissions it needs to manage the
cluster.
</p>
</Field>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending || !roleName.trim()}
onClick={handleCreate}
>
{isPending ? "Creating…" : "Create role"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -31,6 +31,26 @@ export const EKS_ACCESS_POLICIES = [
export const EKS_AMI_TYPES = ["AL2_x86_64", "AL2_ARM_64"] as const
export const EKS_CAPACITY_TYPES = ["ON_DEMAND", "SPOT"] as const
// Trust policy for an EKS cluster IAM role: the EKS control plane assumes it.
export const EKS_CLUSTER_ASSUME_ROLE_POLICY_DOCUMENT = JSON.stringify(
{
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "eks.amazonaws.com" },
Action: "sts:AssumeRole",
},
],
},
null,
2,
)
// Managed policy granting the EKS control plane the permissions it needs.
export const AMAZON_EKS_CLUSTER_POLICY_ARN =
"arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
const eksNameField = z
.string()
.min(1, "Name is required")