feat(api): add Cognito user pools to the cloud explorer #8
@@ -0,0 +1,89 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {CognitoUsersPanel} from './CognitoUsersPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
deleteFlatItem: vi.fn(),
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listFlatItems: vi.fn(),
|
||||||
|
putFlatItem: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const summary: CloudResource = {
|
||||||
|
id: 'us-east-1_8e65d229a', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||||
|
region: 'us-east-1', createdAt: null, metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function pool(signInWith: 'email' | 'username'): CloudResource {
|
||||||
|
return {...summary, metadata: {signInWith, appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alice = {
|
||||||
|
id: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', collectionId: null, timestamp: '2026-10-23T16:01:22.000Z', metadata: {},
|
||||||
|
body: {username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', email: 'alice@example.com', status: 'CONFIRMED', enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><CognitoUsersPanel resource={summary} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
|
||||||
|
api.putFlatItem.mockResolvedValue(alice)
|
||||||
|
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the app client id and the pool users', async () => {
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Copy Client ID'})).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('alice@example.com')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('CONFIRMED')).toBeInTheDocument()
|
||||||
|
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an email user who can sign in immediately', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||||
|
await user.type(screen.getByLabelText('Email'), 'bob@example.com')
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Create user'}))
|
||||||
|
|
||||||
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a username user with a temporary password when asked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('username'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.type(await screen.findByLabelText('Username'), 'bob')
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
|
||||||
|
await user.click(screen.getByLabelText(/User can sign in now/))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Create user'}))
|
||||||
|
|
||||||
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a user by its stored username', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete user alice@example.com'}))
|
||||||
|
|
||||||
|
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id)
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {RefreshCw, Trash2} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||||
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppClient = {clientId: string; clientName: string}
|
||||||
|
|
||||||
|
export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [login, setLogin] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [permanent, setPermanent] = useState(true)
|
||||||
|
const usersKey = ['cognito-users', accountId, resource?.id]
|
||||||
|
|
||||||
|
// The table row is a list summary; sign-in method and app clients come from the full pool.
|
||||||
|
const poolQuery = useQuery({
|
||||||
|
queryKey: ['cognito-pool', accountId, resource?.id],
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'userpools', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: usersKey,
|
||||||
|
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogin('')
|
||||||
|
setPassword('')
|
||||||
|
setPermanent(true)
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username'
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
|
||||||
|
...(signInWithEmail ? {email: login.trim()} : {username: login.trim()}),
|
||||||
|
password,
|
||||||
|
permanent,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setLogin('')
|
||||||
|
setPassword('')
|
||||||
|
return queryClient.invalidateQueries({queryKey: usersKey})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({queryKey: usersKey}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a user pool</h3><p>Choose a user pool to manage its app clients and users.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = usersQuery.data?.items ?? []
|
||||||
|
const appClients = appClientsOf(poolQuery.data)
|
||||||
|
const pending = createMutation.isPending || deleteMutation.isPending
|
||||||
|
const error = poolQuery.error ?? usersQuery.error ?? createMutation.error ?? deleteMutation.error
|
||||||
|
const loginLabel = signInWithEmail ? 'Email' : 'Username'
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Users in ${resource.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
|
||||||
|
</div>
|
||||||
|
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
|
||||||
|
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => (
|
||||||
|
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Users</p><span className="muted">{users.length} users in {resource.name}</span></div>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" type={signInWithEmail ? 'email' : 'text'} value={login} onChange={(event) => setLogin(event.target.value)} autoComplete="off" disabled={pending} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={pending} required/></label>
|
||||||
|
<label className="dynamic-field dynamic-field--span"><span><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={pending}/> User can sign in now</span><small className="muted">When unchecked the password is temporary and must be changed at first sign-in.</small></label>
|
||||||
|
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !login.trim() || !password || pending}>{createMutation.isPending ? 'Creating' : 'Create user'}</button></div>
|
||||||
|
</form>
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
{usersQuery.isLoading && <div className="muted padded">Loading users</div>}
|
||||||
|
{usersQuery.isSuccess && users.length === 0 && <div className="empty compact"><h3>No users</h3><p>Create a user to sign in to apps that use this pool.</p></div>}
|
||||||
|
{users.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>{loginLabel}</th><th>Status</th><th>Enabled</th><th>Created</th><th/></tr></thead><tbody>{users.map((user) => {
|
||||||
|
const name = stringValue(user.body.email) || stringValue(user.body.username)
|
||||||
|
return <tr key={user.id}>
|
||||||
|
<td><span className="copy-value">{name}<CopyValueButton value={name} label={loginLabel}/></span></td>
|
||||||
|
<td><code>{stringValue(user.body.status) || '—'}</code></td>
|
||||||
|
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
||||||
|
<td>{formatDateTime(user.timestamp) ?? '—'}</td>
|
||||||
|
<td><button className="icon-btn danger" type="button" title="Delete user" aria-label={`Delete user ${name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(user.id)}><Trash2 size={14}/></button></td>
|
||||||
|
</tr>
|
||||||
|
})}</tbody></table></div>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function appClientsOf(pool?: CloudResource): AppClient[] {
|
||||||
|
const clients = pool?.metadata.appClients
|
||||||
|
if (!Array.isArray(clients)) return []
|
||||||
|
return clients.filter((client): client is AppClient => typeof client?.clientId === 'string' && client.clientId !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
|
|||||||
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||||
|
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||||
@@ -969,6 +970,9 @@ export function DynamicResourceView({
|
|||||||
{service === "loadbalancing" && cloud === "aws" && (
|
{service === "loadbalancing" && cloud === "aws" && (
|
||||||
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "userpools" && cloud === "aws" && (
|
||||||
|
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
{service === "compute" && (
|
{service === "compute" && (
|
||||||
<ComputePanel
|
<ComputePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Table2,
|
Table2,
|
||||||
|
UsersRound,
|
||||||
Workflow,
|
Workflow,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -29,6 +30,7 @@ import appConfigIcon from '@/assets/aws-services/appconfig.svg'
|
|||||||
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
|
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
|
||||||
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
||||||
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
||||||
|
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||||
@@ -84,6 +86,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
logs: ScrollText,
|
logs: ScrollText,
|
||||||
iam: ShieldCheck,
|
iam: ShieldCheck,
|
||||||
kms: Lock,
|
kms: Lock,
|
||||||
|
userpools: UsersRound,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
parameters: SlidersHorizontal,
|
parameters: SlidersHorizontal,
|
||||||
configuration: SlidersHorizontal,
|
configuration: SlidersHorizontal,
|
||||||
@@ -127,6 +130,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
secrets: secretsManagerIcon,
|
secrets: secretsManagerIcon,
|
||||||
serverless: lambdaIcon,
|
serverless: lambdaIcon,
|
||||||
storage: s3Icon,
|
storage: s3Icon,
|
||||||
|
userpools: cognitoIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4261,6 +4261,11 @@ pre,
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cognito-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.diagnostics {
|
.diagnostics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user