feat(frontend): manage Cognito app clients and users

Adds a users panel under the selected user pool: copyable app client ids, a create-user form that follows the pool's sign-in method, and the user list with delete. Maps the userpools icon key to the Cognito artwork.
This commit is contained in:
Zakaria
2026-10-23 12:12:40 -04:00
parent 4156afccfe
commit fe70e88bcc
5 changed files with 216 additions and 0 deletions
@@ -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 { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -969,6 +970,9 @@ export function DynamicResourceView({
{service === "loadbalancing" && cloud === "aws" && (
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "userpools" && cloud === "aws" && (
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -18,6 +18,7 @@ import {
ShieldCheck,
SlidersHorizontal,
Table2,
UsersRound,
Workflow,
Waypoints,
Zap,
@@ -29,6 +30,7 @@ import appConfigIcon from '@/assets/aws-services/appconfig.svg'
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.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 cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -84,6 +86,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
logs: ScrollText,
iam: ShieldCheck,
kms: Lock,
userpools: UsersRound,
loadbalancing: Scale,
parameters: SlidersHorizontal,
configuration: SlidersHorizontal,
@@ -127,6 +130,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
secrets: secretsManagerIcon,
serverless: lambdaIcon,
storage: s3Icon,
userpools: cognitoIcon,
workflows: stepFunctionsIcon,
}
+5
View File
@@ -4261,6 +4261,11 @@ pre,
gap: 14px;
}
.cognito-table {
overflow-x: auto;
border-bottom: 1px solid var(--border);
}
.diagnostics {
display: grid;
gap: 14px;