feat(api): add ACM certificates to the cloud explorer
CI / checks (pull_request) Successful in 27m28s
CI / checks (pull_request) Successful in 27m28s
Request public certificates (optionally exportable) or import your own, see each domain's DNS validation records and what uses a certificate, download the certificate and chain, export an exportable one with its encrypted key, and revoke it. ListCertificates asks for every key type, since AWS otherwise leaves EC certificates out.
This commit is contained in:
@@ -150,6 +150,11 @@ export const apiEndpointKeys = {
|
||||
state: "clouds.services.monitoring.state",
|
||||
actions: "clouds.services.monitoring.actions",
|
||||
},
|
||||
certificates: {
|
||||
pem: "clouds.services.certificates.pem",
|
||||
export: "clouds.services.certificates.export",
|
||||
revoke: "clouds.services.certificates.revoke",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
@@ -926,6 +931,27 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.certificates.pem,
|
||||
{
|
||||
path: "/clouds/:cloud/services/certificates/resources/:id/pem",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.certificates.export,
|
||||
{
|
||||
path: "/clouds/:cloud/services/certificates/resources/:id/export",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.certificates.revoke,
|
||||
{
|
||||
path: "/clouds/:cloud/services/certificates/resources/:id/revoke",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
{
|
||||
|
||||
@@ -33,6 +33,9 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
ExportedCertificate,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
@@ -1679,6 +1682,44 @@ export async function testWebSocketApi(cloud: CloudProvider, apiId: string, inpu
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCertificatePem(
|
||||
cloud: CloudProvider,
|
||||
certificateId: string,
|
||||
): Promise<CertificatePem> {
|
||||
const res = await apiClient.call<CertificatePem>(
|
||||
apiEndpointKeys.clouds.certificates.pem,
|
||||
requestOptions(cloud, "certificates"),
|
||||
{ cloud, id: certificateId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function exportCertificate(
|
||||
cloud: CloudProvider,
|
||||
certificateId: string,
|
||||
passphrase: string,
|
||||
): Promise<ExportedCertificate> {
|
||||
const res = await apiClient.call<ExportedCertificate, { passphrase: string }>(
|
||||
apiEndpointKeys.clouds.certificates.export,
|
||||
requestOptions(cloud, "certificates", { body: { passphrase } }),
|
||||
{ cloud, id: certificateId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function revokeCertificate(
|
||||
cloud: CloudProvider,
|
||||
certificateId: string,
|
||||
reason: CertificateRevocationReason,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, { reason: CertificateRevocationReason }>(
|
||||
apiEndpointKeys.clouds.certificates.revoke,
|
||||
requestOptions(cloud, "certificates", { body: { reason } }),
|
||||
{ cloud, id: certificateId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CertificatePanel} from './CertificatePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
getCertificatePem: vi.fn(),
|
||||
exportCertificate: vi.fn(),
|
||||
revokeCertificate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
||||
function certificate(metadata: Record<string, unknown> = {}, status = 'ISSUED'): CloudResource {
|
||||
return {
|
||||
id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate',
|
||||
region: 'us-east-1', createdAt: null, status,
|
||||
metadata: {
|
||||
certificateType: 'Amazon issued', exportable: false, inUseBy: [],
|
||||
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CertificatePanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'})
|
||||
api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'})
|
||||
api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED'))
|
||||
})
|
||||
|
||||
test('shows the validation records and that nothing uses the certificate', async () => {
|
||||
renderPanel(certificate())
|
||||
|
||||
expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument()
|
||||
expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument()
|
||||
// Export and revoke exist only for certificates requested as exportable, as on AWS.
|
||||
expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('lists what uses the certificate, since ACM will not delete it then', async () => {
|
||||
renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']}))
|
||||
|
||||
expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument()
|
||||
expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('gets the certificate and chain as downloadable PEM files', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(certificate())
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Get certificate'}))
|
||||
expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF')
|
||||
expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA')
|
||||
expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem')
|
||||
})
|
||||
|
||||
test('exports an exportable certificate with the passphrase, then revokes it', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
renderPanel(certificate({exportable: true}))
|
||||
|
||||
const exportForm = await screen.findByRole('form', {name: 'Export certificate'})
|
||||
await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame')
|
||||
await user.click(within(exportForm).getByRole('button', {name: 'Export'}))
|
||||
expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame')
|
||||
expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----')
|
||||
|
||||
const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'})
|
||||
await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED')
|
||||
await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'}))
|
||||
expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED')
|
||||
expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import {Download, RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null}
|
||||
|
||||
const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [
|
||||
{value: 'UNSPECIFIED', label: 'Unspecified'},
|
||||
{value: 'KEY_COMPROMISE', label: 'Key compromise'},
|
||||
{value: 'SUPERSEDED', label: 'Superseded'},
|
||||
{value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'},
|
||||
{value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'},
|
||||
{value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'},
|
||||
{value: 'CA_COMPROMISE', label: 'CA compromise'},
|
||||
]
|
||||
|
||||
export function CertificatePanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [reason, setReason] = useState<CertificateRevocationReason>('UNSPECIFIED')
|
||||
const [pem, setPem] = useState<CertificatePem | ExportedCertificate | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const certificateKey = ['acm-certificate', accountId, resource?.id]
|
||||
|
||||
// The table row is a ListCertificates summary; validation records and users come from DescribeCertificate.
|
||||
const certificateQuery = useQuery({
|
||||
queryKey: certificateKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPassphrase('')
|
||||
setReason('UNSPECIFIED')
|
||||
setPem(null)
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificateKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}),
|
||||
])
|
||||
|
||||
const pemMutation = useMutation({
|
||||
mutationFn: () => getCertificatePem('aws', resource?.id ?? ''),
|
||||
onSuccess: setPem,
|
||||
})
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase),
|
||||
onSuccess: (exported) => {
|
||||
setPem(exported)
|
||||
setPassphrase('')
|
||||
setNotice('Exported. The private key is encrypted with your passphrase.')
|
||||
},
|
||||
})
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason),
|
||||
onSuccess: () => {
|
||||
setNotice('Certificate revoked. It can no longer be renewed or exported.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a certificate</h3><p>Choose a certificate to see its validation records, where it is used, and to download or export it.</p></div></section>
|
||||
}
|
||||
|
||||
const certificate = certificateQuery.data ?? resource
|
||||
const metadata = certificate.metadata
|
||||
const validations = list<DomainValidation>(metadata.domainValidations)
|
||||
const inUseBy = list<string>(metadata.inUseBy)
|
||||
const exportable = metadata.exportable === true
|
||||
const issued = certificate.status === 'ISSUED'
|
||||
const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error
|
||||
const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_')
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Certificate ${certificate.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Certificate</p><span className="muted">{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); pemMutation.mutate() }}><Download size={14}/>{pemMutation.isPending ? 'Loading' : 'Get certificate'}</button>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || certificateQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{certificateQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validations.length > 0 && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Domains</p><span className="muted">On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.</span></div></div>
|
||||
<div className="cognito-table"><table className="table"><thead><tr><th>Domain</th><th>Validation</th><th>CNAME name</th><th>CNAME value</th></tr></thead><tbody>{validations.map((validation) => (
|
||||
<tr key={validation.domainName}>
|
||||
<td className="mono">{validation.domainName}</td>
|
||||
<td><code>{validation.status ?? '—'}</code>{validation.method ? ` ${validation.method}` : ''}</td>
|
||||
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.name}<CopyValueButton value={validation.record.name} label="CNAME name"/></span> : '—'}</td>
|
||||
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.value}<CopyValueButton value={validation.record.value} label="CNAME value"/></span> : '—'}</td>
|
||||
</tr>
|
||||
))}</tbody></table></div>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">In use by</p><span className="muted">{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}</span></div></div>
|
||||
{inUseBy.length > 0 && <ul className="certificate-users">{inUseBy.map((arn) => <li key={arn} className="mono">{arn}</li>)}</ul>}
|
||||
|
||||
{exportable && issued && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Export</p><span className="muted">Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Export certificate" onSubmit={(event) => { event.preventDefault(); setNotice(''); exportMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Passphrase</span>
|
||||
<input className="input" type="password" autoComplete="new-password" value={passphrase} onChange={(event) => setPassphrase(event.target.value)} disabled={disabled} minLength={4} maxLength={128} required/>
|
||||
<small className="muted">4 to 128 characters, without #, $ or %. You need it to decrypt the key.</small>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || passphrase.length < 4}>{exportMutation.isPending ? 'Exporting' : 'Export'}</button></div>
|
||||
</form>
|
||||
</>}
|
||||
|
||||
{exportable && certificate.status !== 'REVOKED' && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Revoke</p><span className="muted">Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Revoke certificate" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNotice('')
|
||||
if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Reason</span>
|
||||
<select className="input" value={reason} onChange={(event) => setReason(event.target.value as CertificateRevocationReason)} disabled={disabled}>
|
||||
{REVOCATION_REASONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div><button className="button danger" type="submit" disabled={disabled}>{revokeMutation.isPending ? 'Revoking' : 'Revoke'}</button></div>
|
||||
</form>
|
||||
</>}
|
||||
|
||||
{pem && <div className="certificate-pem">
|
||||
<PemBlock label="Certificate" value={pem.certificate} file={`${fileStem}.pem`}/>
|
||||
{pem.certificateChain && <PemBlock label="Certificate chain" value={pem.certificateChain} file={`${fileStem}-chain.pem`}/>}
|
||||
{'privateKey' in pem && <PemBlock label="Private key (encrypted)" value={pem.privateKey} file={`${fileStem}-key.pem`}/>}
|
||||
</div>}
|
||||
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function PemBlock({label, value, file}: {label: string; value: string; file: string}) {
|
||||
const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}`
|
||||
return <div className="certificate-pem-block">
|
||||
<div className="certificate-pem-head">
|
||||
<strong>{label}</strong>
|
||||
<CopyValueButton value={value} label={label}/>
|
||||
<a className="button" href={href} download={file}><Download size={13}/> {file}</a>
|
||||
</div>
|
||||
<textarea className="textarea code-textarea small" rows={6} value={value} readOnly aria-label={label}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function list<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value as T[] : []
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { CertificatePanel } from "@/components/CertificatePanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
@@ -987,6 +988,9 @@ export function DynamicResourceView({
|
||||
{service === "dns" && cloud === "aws" && (
|
||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "certificates" && cloud === "aws" && (
|
||||
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Container,
|
||||
Database,
|
||||
Earth,
|
||||
FileBadge,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hammer,
|
||||
@@ -36,6 +37,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
|
||||
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
||||
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
@@ -95,6 +97,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
certificates: FileBadge,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
@@ -142,6 +145,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
certificates: certificateManagerIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
@@ -6610,3 +6610,9 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.metric-chart-table { margin-top: 8px; }
|
||||
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
||||
.metric-chart-table table { margin-top: 8px; }
|
||||
|
||||
/* ACM: the resources using a certificate, and its PEM blocks. */
|
||||
.certificate-users { margin: 0; padding: 0 16px 12px 32px; display: grid; gap: 4px; overflow-wrap: anywhere; }
|
||||
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
|
||||
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
|
||||
.certificate-pem-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
|
||||
@@ -337,6 +337,25 @@ export interface AlarmHistoryItem {
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface CertificatePem {
|
||||
certificate: string
|
||||
certificateChain: string | null
|
||||
}
|
||||
|
||||
/** The private key is PEM, encrypted with the passphrase given to export. */
|
||||
export interface ExportedCertificate extends CertificatePem {
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export type CertificateRevocationReason =
|
||||
| 'UNSPECIFIED'
|
||||
| 'KEY_COMPROMISE'
|
||||
| 'CA_COMPROMISE'
|
||||
| 'AFFILIATION_CHANGED'
|
||||
| 'SUPERSEDED'
|
||||
| 'CESSATION_OF_OPERATION'
|
||||
| 'PRIVILEGE_WITHDRAWN'
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
|
||||
@@ -28,6 +28,7 @@ export type KubernetesActionName =
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
@@ -91,6 +92,7 @@ export interface ServiceSchema {
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
|
||||
Reference in New Issue
Block a user