diff --git a/packages/frontend/src/components/CognitoUsersPanel.test.tsx b/packages/frontend/src/components/CognitoUsersPanel.test.tsx new file mode 100644 index 0000000..d716492 --- /dev/null +++ b/packages/frontend/src/components/CognitoUsersPanel.test.tsx @@ -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() +} + +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) +}) diff --git a/packages/frontend/src/components/CognitoUsersPanel.tsx b/packages/frontend/src/components/CognitoUsersPanel.tsx new file mode 100644 index 0000000..bb6fcb5 --- /dev/null +++ b/packages/frontend/src/components/CognitoUsersPanel.tsx @@ -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

Select a user pool

Choose a user pool to manage its app clients and users.

+ } + + 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
+
+

App clients

Use a client ID with the pool ID to sign users in from your app
+
+ {poolQuery.isSuccess && appClients.length === 0 &&

This pool has no app clients.

} + {appClients.length > 0 &&
{appClients.map((client) => ( + + ))}
NameClient ID
{client.clientName || '—'}{client.clientId}
} + +
+

Users

{users.length} users in {resource.name}
+ +
+
{ event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}> + + + +
+
+ {error instanceof Error &&

{error.message}

} + {usersQuery.isLoading &&
Loading users
} + {usersQuery.isSuccess && users.length === 0 &&

No users

Create a user to sign in to apps that use this pool.

} + {users.length > 0 &&
{users.map((user) => { + const name = stringValue(user.body.email) || stringValue(user.body.username) + return + + + + + + + })}
{loginLabel}StatusEnabledCreated
{name}{stringValue(user.body.status) || '—'}{user.body.enabled === false ? 'No' : 'Yes'}{formatDateTime(user.timestamp) ?? '—'}
} +
+} + +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 : '' +} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 880d43c..c8c201d 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -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" && ( )} + {service === "userpools" && cloud === "aws" && ( + + )} {service === "compute" && ( = { logs: ScrollText, iam: ShieldCheck, kms: Lock, + userpools: UsersRound, loadbalancing: Scale, parameters: SlidersHorizontal, configuration: SlidersHorizontal, @@ -127,6 +130,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = { secrets: secretsManagerIcon, serverless: lambdaIcon, storage: s3Icon, + userpools: cognitoIcon, workflows: stepFunctionsIcon, } diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index db17acb..71fe441 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -4261,6 +4261,11 @@ pre, gap: 14px; } +.cognito-table { + overflow-x: auto; + border-bottom: 1px solid var(--border); +} + .diagnostics { display: grid; gap: 14px;