feat: CORS enable button...ish still now understanding this

This commit is contained in:
Zakaria
2026-09-27 14:46:04 -04:00
parent 8470b8d17a
commit b474e823ce
17 changed files with 943 additions and 31 deletions
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createApiGatewayLambdaRoute: vi.fn(),
deleteApiGatewayRoute: vi.fn(),
enableApiGatewayCors: vi.fn(),
listApiGatewayRoutes: vi.fn(),
listCloudResources: vi.fn(),
queryLogs: vi.fn(),
@@ -54,6 +55,39 @@ test('creates and displays a deployed Lambda proxy route', async () => {
expect(await screen.findByText('Route deployed')).toBeInTheDocument()
})
test('displays and copies the selected API stage base invoke URL', async () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable runtimeEndpoint="http://floci.test:4566"/>
</QueryClientProvider>,
)
const baseInvokeUrl = 'http://floci.test:4566/execute-api/api-123/dev'
expect(screen.getByText(baseInvokeUrl)).toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Copy Base invoke URL'}))
expect(writeText).toHaveBeenCalledWith(baseInvokeUrl)
})
test('uses the browser host for a Docker bridge runtime endpoint', () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable runtimeEndpoint="http://172.17.0.3:4566"/>
</QueryClientProvider>,
)
expect(screen.getByText('http://localhost:4566/execute-api/api-123/dev')).toBeInTheDocument()
})
test('sends a route test request and displays its response', async () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
@@ -144,3 +178,29 @@ test('confirms before deleting a deployed route', async () => {
resourceId: 'resource-123', path: '/orders', method: 'POST', stage: 'dev',
}))
})
test('enables CORS for one resource and shows AWS proxy guidance', async () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({
routes: [
{resourceId: 'resource-123', path: '/orders', method: 'GET', functionName: null, integrationType: 'HTTP'},
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler', integrationType: 'AWS_PROXY'},
], stages: ['dev'],
})
apiMocks.enableApiGatewayCors.mockResolvedValue({configuredMethods: ['GET'], proxyMethods: ['POST']})
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
await user.click(await screen.findByRole('button', {name: 'Enable CORS'}))
expect(screen.getByDisplayValue('*')).toBeInTheDocument()
expect(screen.getByLabelText('GET')).toBeChecked()
expect(screen.getByLabelText('POST')).toBeChecked()
await user.click(screen.getByRole('button', {name: 'Confirm enable CORS'}))
await waitFor(() => expect(apiMocks.enableApiGatewayCors).toHaveBeenCalledWith('aws', 'api-123', {
resourceId: 'resource-123', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
}))
expect(await screen.findByText(/AWS_PROXY methods need their backend/)).toBeInTheDocument()
expect(screen.getByText(/Access-Control-Allow-Origin/)).toBeInTheDocument()
})
@@ -1,18 +1,20 @@
import {useEffect, useState} from 'react'
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {CopyValueButton} from './CopyValueButton'
import {LogsQueryPanel} from './LogsQueryPanel'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
runtimeEndpoint?: string
}
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) {
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
const [path, setPath] = useState('/hello')
const [method, setMethod] = useState('GET')
const [stage, setStage] = useState('dev')
@@ -24,6 +26,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
const [deleteConfirmation, setDeleteConfirmation] = useState<string | null>(null)
const [corsConfirmation, setCorsConfirmation] = useState<{resourceId: string; path: string} | null>(null)
const [corsOrigin, setCorsOrigin] = useState('*')
const [corsHeaders, setCorsHeaders] = useState('Content-Type')
const [corsMethods, setCorsMethods] = useState<string[]>([])
const [corsProxyMethods, setCorsProxyMethods] = useState<string[]>([])
const queryClient = useQueryClient()
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
@@ -48,6 +55,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
setBodyFormatError(null)
setLogsRefreshKey(0)
setDeleteConfirmation(null)
setCorsConfirmation(null)
setCorsOrigin('*')
setCorsHeaders('Content-Type')
setCorsMethods([])
setCorsProxyMethods([])
}, [resource?.id])
const routeMutation = useMutation({
@@ -73,6 +85,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
},
})
const corsMutation = useMutation({
mutationFn: () => enableApiGatewayCors(cloud, resource!.id, {
resourceId: corsConfirmation!.resourceId,
stage,
origin: corsOrigin,
headers: corsHeaders,
methods: corsMethods,
}),
onSuccess: (result) => {
setCorsProxyMethods(result.proxyMethods)
setCorsConfirmation(null)
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
},
})
if (!resource || resource.service !== 'apigateway') {
return (
@@ -89,6 +115,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
const endpoint = invokeEndpoint(runtimeEndpoint)
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
return (
<section className="table-panel">
@@ -108,6 +136,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
)}
</div>
<div className="resource-create-inline">
{baseInvokeUrl && <div className="dynamic-field">
<span>Base invoke URL</span>
<div className="copy-value">
<code>{baseInvokeUrl}</code>
<CopyValueButton value={baseInvokeUrl} label="Base invoke URL"/>
</div>
</div>}
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) routeMutation.mutate()
@@ -161,12 +196,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
{routesQuery.isLoading && <p className="muted compact-text">Loading deployed routes...</p>}
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No deployed methods yet. Create and deploy a route above.</p>}
{routesQuery.data && routesQuery.data.routes.length > 0 && (
<div className="apigateway-route-list">
{routesQuery.data.routes.map((route) => {
{routesQuery.data && routesQuery.data.routes.length > 0 && (
<div className="apigateway-route-list">
{routesQuery.data.routes.map((route) => {
const selected = route.path === path && route.method === method
const key = `${route.resourceId}-${route.method}`
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
const resourceMethods = routesQuery.data.routes
.filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method))
.map((item) => item.method)
const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route)
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
<button className="apigateway-route-select" type="button" onClick={() => {
setPath(route.path)
setMethod(route.method)
@@ -176,18 +215,34 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
}}>
<span className="apigateway-route-method">{route.method}</span>
<code>{route.path}</code>
<small>{route.functionName ?? 'No Lambda integration'}</small>
</button>
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
</button>
{isFirstForResource && <button className="button compact" type="button" onClick={() => {
setCorsConfirmation({resourceId: route.resourceId, path: route.path})
setCorsMethods(resourceMethods)
corsMutation.reset()
}}>Enable CORS</button>}
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
<span>Delete {route.method}?</span>
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
</div>
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
{corsConfirmation?.resourceId === route.resourceId && isFirstForResource && <div className="apigateway-route-confirm">
<strong>Enable CORS for {corsConfirmation.path}</strong>
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((method) => method !== value) : [...current, value])}/>{value}</label>)}</div>
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
</div>}
</div>
})}
</div>
)}
</div>
</div>
)}
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
</div>
<div className="apigateway-test-panel">
<div className="dynamic-stage-header">
<div>
@@ -299,6 +354,45 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
)
}
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) {
return <div className="error-text compact-text">
<strong>AWS_PROXY methods need their backend to return CORS headers: {methods.join(', ')}.</strong>
<pre>{`import "net/http"
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "${origin}")
w.Header().Set("Access-Control-Allow-Headers", "${headers}")
w.Header().Set("Access-Control-Allow-Methods", "${methods.join(',')},OPTIONS")
if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK); return }
// Handle the proxy request.
}`}</pre>
</div>
}
function invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
function parseHeaders(value: string): Record<string, string> {
let parsed: unknown
try {
@@ -20,6 +20,7 @@ import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
import { ResourceInspector } from "@/components/ResourceInspector";
import { ResourceTable } from "@/components/ResourceTable";
import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
import { NetworkingPanel } from "@/components/NetworkingPanel";
import { getPath } from "@/lib/resourcePath";
@@ -848,14 +849,17 @@ export function DynamicResourceView({
)}
</div>
{service === "storage" && (
<StorageObjectBrowser
cloud={cloud}
resource={selected}
capabilities={objectCapabilities}
runtimeReachable={canUseRuntime}
selectedObjectKey={selectedObject?.key}
onSelectObject={setSelectedObject}
/>
<>
<StorageObjectBrowser
cloud={cloud}
resource={selected}
capabilities={objectCapabilities}
runtimeReachable={canUseRuntime}
selectedObjectKey={selectedObject?.key}
onSelectObject={setSelectedObject}
/>
{cloud === "aws" && <S3BucketWebsitePanel resource={selected} runtimeReachable={canUseRuntime} />}
</>
)}
{service === "ecr" && cloud === "aws" && (
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
@@ -912,6 +916,7 @@ export function DynamicResourceView({
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
)}
{service === "events" && (
@@ -0,0 +1,54 @@
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {S3BucketWebsitePanel} from './S3BucketWebsitePanel'
const api = vi.hoisted(() => ({
getS3BucketWebsite: vi.fn(),
putS3BucketWebsite: vi.fn(),
deleteS3BucketWebsite: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
const bucket = {id: 'assets', name: 'assets', cloud: 'aws' as const, service: 'storage' as const, type: 'bucket', region: 'us-west-2', createdAt: null, metadata: {}}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><S3BucketWebsitePanel resource={bucket} runtimeReachable/></QueryClientProvider>)
}
test('loads website configuration, shows its regional endpoint, and saves the index-only AWS shape', async () => {
api.getS3BucketWebsite.mockResolvedValue({mode: 'index', indexDocument: 'home.html', errorDocument: 'error.html'})
api.putS3BucketWebsite.mockResolvedValue(undefined)
const user = userEvent.setup()
renderPanel()
expect(await screen.findByDisplayValue('home.html')).toBeVisible()
expect(screen.getByText('http://assets.s3-website-us-west-2.localhost:4566')).toBeVisible()
await user.clear(screen.getByLabelText(/Error document/))
await user.click(screen.getByRole('button', {name: 'Save changes'}))
await waitFor(() => expect(api.putS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets', {mode: 'index', indexDocument: 'home.html', errorDocument: null}))
})
test('switches to redirect mode and confirms disable before deleting', async () => {
api.getS3BucketWebsite.mockResolvedValue({mode: 'index', indexDocument: 'index.html', errorDocument: null})
api.putS3BucketWebsite.mockResolvedValue(undefined)
api.deleteS3BucketWebsite.mockResolvedValue(undefined)
const user = userEvent.setup()
renderPanel()
await screen.findByText('Enabled')
await user.selectOptions(screen.getByLabelText('Hosting mode'), 'redirect')
await user.type(screen.getByLabelText('Redirect host name'), 'www.example.com')
await user.selectOptions(screen.getByLabelText('Redirect protocol'), 'https')
await user.click(screen.getByRole('button', {name: 'Save changes'}))
await waitFor(() => expect(api.putS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}))
await user.click(screen.getByRole('button', {name: 'Disable website hosting'}))
expect(screen.getByText('Disable static website hosting?')).toBeVisible()
await user.click(screen.getByRole('button', {name: 'Disable'}))
await waitFor(() => expect(api.deleteS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets'))
})
@@ -0,0 +1,108 @@
import {useEffect, useState} from 'react'
import {Globe, Loader2, RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {deleteS3BucketWebsite, getS3BucketWebsite, putS3BucketWebsite} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource, S3BucketWebsiteConfiguration} from '@/types/resource'
interface S3BucketWebsitePanelProps {
resource?: CloudResource
runtimeReachable: boolean
}
export function S3BucketWebsitePanel({resource, runtimeReachable}: S3BucketWebsitePanelProps) {
const queryClient = useQueryClient()
const accountId = useAccountId()
const [mode, setMode] = useState<'index' | 'redirect'>('index')
const [indexDocument, setIndexDocument] = useState('index.html')
const [errorDocument, setErrorDocument] = useState('')
const [hostName, setHostName] = useState('')
const [protocol, setProtocol] = useState<'http' | 'https' | ''>('')
const [confirmDisable, setConfirmDisable] = useState(false)
const queryKey = ['s3-bucket-website', accountId, resource?.id]
const websiteQuery = useQuery({
queryKey,
queryFn: ({signal}) => getS3BucketWebsite('aws', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
const configuration = websiteQuery.data
if (!configuration) {
setMode('index')
setIndexDocument('index.html')
setErrorDocument('')
setHostName('')
setProtocol('')
return
}
setMode(configuration.mode)
if (configuration.mode === 'index') {
setIndexDocument(configuration.indexDocument)
setErrorDocument(configuration.errorDocument ?? '')
} else {
setHostName(configuration.hostName)
setProtocol(configuration.protocol ?? '')
}
}, [websiteQuery.data, resource?.id])
const saveMutation = useMutation({
mutationFn: (configuration: S3BucketWebsiteConfiguration) => putS3BucketWebsite('aws', resource?.id ?? '', configuration),
onSuccess: () => {
void queryClient.invalidateQueries({queryKey})
},
})
const deleteMutation = useMutation({
mutationFn: () => deleteS3BucketWebsite('aws', resource?.id ?? ''),
onSuccess: () => {
setConfirmDisable(false)
void queryClient.setQueryData<S3BucketWebsiteConfiguration | null>(queryKey, null)
void queryClient.invalidateQueries({queryKey})
},
})
if (!resource || resource.service !== 'storage' || resource.type !== 'bucket') return null
const endpoint = `http://${resource.name}.s3-website-${resource.region ?? 'us-east-1'}.localhost:4566`
const configuration = mode === 'index'
? {mode, indexDocument: indexDocument.trim(), errorDocument: errorDocument.trim() || null}
: {mode, hostName: hostName.trim(), protocol: protocol || null}
const canSave = !saveMutation.isPending && (mode === 'index' ? Boolean(configuration.indexDocument) : Boolean(configuration.hostName))
return <section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Static website hosting</p>
<h3><Globe size={15}/> {websiteQuery.data ? 'Enabled' : 'Not configured'}</h3>
<p className="muted compact-text">Website endpoint: <a href={endpoint} target="_blank" rel="noreferrer"><code>{endpoint}</code></a></p>
</div>
<button className="icon-btn" type="button" aria-label="Refresh website configuration" onClick={() => websiteQuery.refetch()} disabled={websiteQuery.isFetching}>
{websiteQuery.isFetching ? <Loader2 size={13}/> : <RefreshCw size={13}/>}
</button>
</div>
{websiteQuery.isLoading && <p className="muted compact-text">Loading website configuration...</p>}
{websiteQuery.isError && <p className="error-text compact-text">{websiteQuery.error instanceof Error ? websiteQuery.error.message : 'Unable to load website configuration.'}</p>}
<form className="resource-create-inline" onSubmit={(event) => {
event.preventDefault()
if (canSave) saveMutation.mutate(configuration)
}}>
<label className="dynamic-field" htmlFor="s3-website-mode">
<span>Hosting mode</span>
<select id="s3-website-mode" className="input" value={mode} onChange={(event) => setMode(event.target.value as 'index' | 'redirect')} disabled={saveMutation.isPending}>
<option value="index">Index and error documents</option>
<option value="redirect">Redirect all requests</option>
</select>
</label>
{mode === 'index' ? <>
<label className="dynamic-field" htmlFor="s3-website-index-document"><span>Index document</span><input id="s3-website-index-document" className="input" value={indexDocument} onChange={(event) => setIndexDocument(event.target.value)} placeholder="index.html" required disabled={saveMutation.isPending}/></label>
<label className="dynamic-field" htmlFor="s3-website-error-document"><span>Error document</span><input id="s3-website-error-document" className="input" value={errorDocument} onChange={(event) => setErrorDocument(event.target.value)} placeholder="error.html" disabled={saveMutation.isPending}/><small>Optional.</small></label>
</> : <>
<label className="dynamic-field" htmlFor="s3-website-redirect-host"><span>Redirect host name</span><input id="s3-website-redirect-host" className="input" value={hostName} onChange={(event) => setHostName(event.target.value)} placeholder="www.example.com" required disabled={saveMutation.isPending}/></label>
<label className="dynamic-field" htmlFor="s3-website-redirect-protocol"><span>Redirect protocol</span><select id="s3-website-redirect-protocol" className="input" value={protocol} onChange={(event) => setProtocol(event.target.value as 'http' | 'https' | '')} disabled={saveMutation.isPending}><option value="">Preserve request protocol</option><option value="http">HTTP</option><option value="https">HTTPS</option></select></label>
</>}
<button className="button primary" type="submit" disabled={!canSave}>{saveMutation.isPending ? <Loader2 size={13}/> : null}{websiteQuery.data ? 'Save changes' : 'Enable website hosting'}</button>
</form>
{saveMutation.isError && <p className="error-text compact-text">{saveMutation.error instanceof Error ? saveMutation.error.message : 'Unable to save website configuration.'}</p>}
{websiteQuery.data && (confirmDisable ? <div className="apigateway-route-confirm"><span>Disable static website hosting?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate()}>{deleteMutation.isPending ? 'Disabling' : 'Disable'}</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDisable(false)}>Cancel</button></div> : <button className="button danger compact" type="button" onClick={() => setConfirmDisable(true)}><Trash2 size={13}/> Disable website hosting</button>)}
{deleteMutation.isError && <p className="error-text compact-text">{deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Unable to disable website hosting.'}</p>}
</section>
}