From bafe799d00fe7d58de2b862950d1984b055f2ed0 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 17:54:15 -0400 Subject: [PATCH] feat(frontend): manage Cognito users and groups, and test sign-in The Cognito panel gains a test sign-in that shows the decoded ID and access token claims, a groups section, and a per-user panel to enable or disable, confirm, sign out everywhere, reset or set a password and change group membership. Delete confirmations (row and bulk) now say what else goes, driven by the schema's deleteImpact, and the inspector follows list refreshes instead of the click-time snapshot. --- packages/frontend/src/api/api.ts | 60 ++++++++ packages/frontend/src/api/cloudProxyClient.ts | 97 +++++++++++++ .../src/components/CognitoGroupsSection.tsx | 64 ++++++++ .../src/components/CognitoSignInTest.tsx | 73 ++++++++++ .../src/components/CognitoUserDetail.tsx | 137 ++++++++++++++++++ .../src/components/CognitoUsersPanel.test.tsx | 80 +++++++++- .../src/components/CognitoUsersPanel.tsx | 39 ++++- .../src/components/DynamicResourceView.tsx | 8 +- .../src/components/ResourceTable.test.tsx | 37 +++++ .../frontend/src/components/ResourceTable.tsx | 7 + packages/frontend/src/index.css | 104 +++++++++++++ .../frontend/src/lib/deleteImpact.test.ts | 22 +++ packages/frontend/src/lib/deleteImpact.ts | 25 ++++ packages/frontend/src/types/resource.ts | 32 ++++ packages/frontend/src/types/schema.ts | 4 + 15 files changed, 773 insertions(+), 16 deletions(-) create mode 100644 packages/frontend/src/components/CognitoGroupsSection.tsx create mode 100644 packages/frontend/src/components/CognitoSignInTest.tsx create mode 100644 packages/frontend/src/components/CognitoUserDetail.tsx create mode 100644 packages/frontend/src/components/ResourceTable.test.tsx create mode 100644 packages/frontend/src/lib/deleteImpact.test.ts create mode 100644 packages/frontend/src/lib/deleteImpact.ts diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 0a4f2f1..c7673ce 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -130,6 +130,16 @@ export const apiEndpointKeys = { messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"}, moveTasks: "clouds.services.messaging.move-tasks", }, + userpools: { + userAction: "clouds.services.userpools.users.action", + userGroups: "clouds.services.userpools.users.groups", + groups: { + list: "clouds.services.userpools.groups.list", + create: "clouds.services.userpools.groups.create", + delete: "clouds.services.userpools.groups.delete", + }, + signInTest: "clouds.services.userpools.sign-in-test", + }, k8s: { nodegroups: { list: "clouds.services.k8s.nodegroups.list", @@ -154,6 +164,7 @@ export const apiEndpointKeys = { }, }, flatItems: { + get: "clouds.services.flatItems.get", list: "clouds.services.flatItems.list", query: "clouds.services.flatItems.query", create: "clouds.services.flatItems.create", @@ -829,6 +840,48 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "GET", }, ], + [ + apiEndpointKeys.clouds.userpools.userAction, + { + path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/actions", + method: "POST", + }, + ], + [ + apiEndpointKeys.clouds.userpools.userGroups, + { + path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/groups", + method: "PUT", + }, + ], + [ + apiEndpointKeys.clouds.userpools.groups.list, + { + path: "/clouds/:cloud/services/userpools/resources/:id/groups", + method: "GET", + }, + ], + [ + apiEndpointKeys.clouds.userpools.groups.create, + { + path: "/clouds/:cloud/services/userpools/resources/:id/groups", + method: "POST", + }, + ], + [ + apiEndpointKeys.clouds.userpools.groups.delete, + { + path: "/clouds/:cloud/services/userpools/resources/:id/groups/:groupName", + method: "DELETE", + }, + ], + [ + apiEndpointKeys.clouds.userpools.signInTest, + { + path: "/clouds/:cloud/services/userpools/resources/:id/sign-in-test", + method: "POST", + }, + ], [ apiEndpointKeys.clouds.k8s.nodegroups.list, { @@ -998,6 +1051,13 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "GET", }, ], + [ + apiEndpointKeys.clouds.flatItems.get, + { + path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId", + method: "GET", + }, + ], [ apiEndpointKeys.clouds.flatItems.list, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 7a387e3..0f5da65 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -27,6 +27,11 @@ import type { DatabaseSnapshot, KubernetesFargateProfile, KubernetesNodegroup, + CreateUserPoolGroupInput, + UserPoolGroup, + UserPoolSignInTestInput, + UserPoolSignInTestResult, + UserPoolUserAction, LogsInsightsQueryInput, LogsInsightsQueryResult, LogsFilterInput, @@ -1377,6 +1382,98 @@ export async function deleteFlatItem( ); } +export async function getFlatItem( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + itemId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.flatItems.get, + requestOptions(cloud, service, { signal }), + { cloud, service, id: resourceId, itemId }, + ); + return res.data; +} + +export async function updateUserPoolUser( + cloud: CloudProvider, + poolId: string, + username: string, + input: UserPoolUserAction, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.userpools.userAction, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId, username }, + ); +} + +export async function setUserPoolUserGroups( + cloud: CloudProvider, + poolId: string, + username: string, + groups: string[], +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.userpools.userGroups, + requestOptions(cloud, "userpools", { body: { groups } }), + { cloud, id: poolId, username }, + ); +} + +export async function listUserPoolGroups( + cloud: CloudProvider, + poolId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.groups.list, + requestOptions(cloud, "userpools", { signal }), + { cloud, id: poolId }, + ); + return res.data; +} + +export async function createUserPoolGroup( + cloud: CloudProvider, + poolId: string, + input: CreateUserPoolGroupInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.groups.create, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId }, + ); + return res.data; +} + +export async function deleteUserPoolGroup( + cloud: CloudProvider, + poolId: string, + groupName: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.userpools.groups.delete, + requestOptions(cloud, "userpools"), + { cloud, id: poolId, groupName }, + ); +} + +export async function testUserPoolSignIn( + cloud: CloudProvider, + poolId: string, + input: UserPoolSignInTestInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.signInTest, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId }, + ); + return res.data; +} + export async function putFlatItem( cloud: CloudProvider, service: CloudServiceType, diff --git a/packages/frontend/src/components/CognitoGroupsSection.tsx b/packages/frontend/src/components/CognitoGroupsSection.tsx new file mode 100644 index 0000000..0fa27f9 --- /dev/null +++ b/packages/frontend/src/components/CognitoGroupsSection.tsx @@ -0,0 +1,64 @@ +import {Trash2} from 'lucide-react' +import {useMutation, useQueryClient, type UseQueryResult} from '@tanstack/react-query' +import {useState} from 'react' +import {createUserPoolGroup, deleteUserPoolGroup} from '@/api/cloudProxyClient' +import type {UserPoolGroup} from '@/types/resource' + +type Props = { + poolId: string + groupsQuery: UseQueryResult + groupsKey: unknown[] + runtimeReachable: boolean +} + +export function CognitoGroupsSection({poolId, groupsQuery, groupsKey, runtimeReachable}: Props) { + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [precedence, setPrecedence] = useState('') + const refresh = () => queryClient.invalidateQueries({queryKey: groupsKey}) + + const createMutation = useMutation({ + mutationFn: () => createUserPoolGroup('aws', poolId, { + name: name.trim(), + ...(description.trim() ? {description: description.trim()} : {}), + ...(precedence.trim() ? {precedence: Number(precedence)} : {}), + }), + onSuccess: () => { + setName('') + setDescription('') + setPrecedence('') + return refresh() + }, + }) + const deleteMutation = useMutation({ + mutationFn: (groupName: string) => deleteUserPoolGroup('aws', poolId, groupName), + onSuccess: refresh, + }) + + const groups = groupsQuery.data ?? [] + const pending = createMutation.isPending || deleteMutation.isPending + const error = groupsQuery.error ?? createMutation.error ?? deleteMutation.error + + return <> +
+

Groups

Group names appear in the cognito:groups token claim, so apps can check roles
+
+
{ event.preventDefault(); if (name.trim()) createMutation.mutate() }}> + + + +
+
+ {error instanceof Error &&

{error.message}

} + {groupsQuery.isSuccess && groups.length === 0 &&

No groups yet.

} + {groups.length > 0 &&
{groups.map((group) => ( + + + + + + + ))}
NameDescriptionPrecedence
{group.name}{group.description || '—'}{group.precedence ?? '—'}
} + +} diff --git a/packages/frontend/src/components/CognitoSignInTest.tsx b/packages/frontend/src/components/CognitoSignInTest.tsx new file mode 100644 index 0000000..e6f9bc0 --- /dev/null +++ b/packages/frontend/src/components/CognitoSignInTest.tsx @@ -0,0 +1,73 @@ +import {useMutation} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {testUserPoolSignIn} from '@/api/cloudProxyClient' + +type Props = { + poolId: string + appClients: Array<{clientId: string; clientName: string}> + loginLabel: string + runtimeReachable: boolean +} + +const KEY_CLAIMS = ['sub', 'email', 'cognito:username', 'cognito:groups', 'client_id', 'aud', 'token_use', 'exp'] + +export function CognitoSignInTest({poolId, appClients, loginLabel, runtimeReachable}: Props) { + const [clientId, setClientId] = useState(appClients[0]?.clientId ?? '') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + useEffect(() => { + if (!appClients.some((client) => client.clientId === clientId)) setClientId(appClients[0]?.clientId ?? '') + }, [appClients, clientId]) + + const signIn = useMutation({ + mutationFn: () => testUserPoolSignIn('aws', poolId, {clientId, username: username.trim(), password}), + }) + const result = signIn.data + + if (appClients.length === 0) return null + + return <> +
+

Test sign-in

Signs in the way an app does and shows what is inside the tokens
+
+
{ event.preventDefault(); if (clientId && username.trim() && password) signIn.mutate() }}> + + + +
+
+ {signIn.error instanceof Error &&

{signIn.error.message}

} + {result?.outcome === 'rejected' &&

Refused {result.code} {result.message}

} + {result?.outcome === 'challenge' &&

Cognito wants another step {result.challengeName} {challengeHint(result.challengeName)}

} + {result?.outcome === 'signed-in' &&
+

Signed in. {result.expiresIn ? `The ID and access tokens are valid for ${Math.round(result.expiresIn / 60)} minutes.` : ''}

+
+ + +
+
} + +} + +function ClaimsTable({title, claims}: {title: string; claims: Record}) { + const keys = [...KEY_CLAIMS.filter((key) => key in claims), ...Object.keys(claims).filter((key) => !KEY_CLAIMS.includes(key)).sort()] + return
+

{title}

+ {keys.map((key) => ( + + ))}
{key}{formatClaim(key, claims[key])}
+
+} + +function formatClaim(key: string, value: unknown): string { + if ((key === 'exp' || key === 'iat' || key === 'auth_time') && typeof value === 'number') return `${value} (${new Date(value * 1000).toLocaleString()})` + if (Array.isArray(value)) return value.join(', ') + return typeof value === 'string' ? value : JSON.stringify(value) +} + +function challengeHint(challenge: string): string { + if (challenge === 'NEW_PASSWORD_REQUIRED') return 'The password is temporary: the user must choose a new one before getting tokens.' + if (challenge.includes('MFA') || challenge === 'SOFTWARE_TOKEN_MFA') return 'The user must enter a second-factor code.' + return 'The app has to answer this challenge before Cognito issues tokens.' +} diff --git a/packages/frontend/src/components/CognitoUserDetail.tsx b/packages/frontend/src/components/CognitoUserDetail.tsx new file mode 100644 index 0000000..dd144bd --- /dev/null +++ b/packages/frontend/src/components/CognitoUserDetail.tsx @@ -0,0 +1,137 @@ +import {X} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {getFlatItem, setUserPoolUserGroups, updateUserPoolUser} from '@/api/cloudProxyClient' +import {useAccountId} from '@/lib/accountStore' +import {formatDateTime} from '@/lib/format' +import type {UserPoolGroup, UserPoolUserAction} from '@/types/resource' + +type Props = { + poolId: string + username: string + groups: UserPoolGroup[] + usersKey: unknown[] + runtimeReachable: boolean + onClose: () => void +} + +const ACTION_DONE: Record = { + enable: 'User enabled.', + disable: 'User disabled. New sign-ins are refused; tokens already issued last until they expire.', + confirm: 'User confirmed.', + resetPassword: 'Password reset. The user must use a reset code to sign in again.', + signOut: 'Signed out everywhere. Refresh tokens no longer work.', + setPassword: 'Password set.', +} + +export function CognitoUserDetail({poolId, username, groups, usersKey, runtimeReachable, onClose}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const userKey = ['cognito-user', accountId, poolId, username] + const [selectedGroups, setSelectedGroups] = useState([]) + const [password, setPassword] = useState('') + const [permanent, setPermanent] = useState(true) + const [notice, setNotice] = useState('') + + const userQuery = useQuery({ + queryKey: userKey, + queryFn: ({signal}) => getFlatItem('aws', 'userpools', poolId, username, signal), + enabled: runtimeReachable, + }) + const user = userQuery.data?.body + const currentGroups = stringList(user?.groups) + + // Reset the checkboxes only when the stored membership changes, not on every render. + const storedGroups = currentGroups.join('\n') + useEffect(() => { + setSelectedGroups(storedGroups ? storedGroups.split('\n') : []) + }, [storedGroups]) + + const refresh = () => Promise.all([queryClient.invalidateQueries({queryKey: userKey}), queryClient.invalidateQueries({queryKey: usersKey})]) + const actionMutation = useMutation({ + mutationFn: (input: UserPoolUserAction) => updateUserPoolUser('aws', poolId, username, input), + onSuccess: (_result, input) => { + setNotice(ACTION_DONE[input.action]) + if (input.action === 'setPassword') setPassword('') + return refresh() + }, + }) + const groupsMutation = useMutation({ + mutationFn: () => setUserPoolUserGroups('aws', poolId, username, selectedGroups), + onSuccess: () => { + setNotice('Groups saved. They appear in tokens issued from the next sign-in.') + return refresh() + }, + }) + + const pending = actionMutation.isPending || groupsMutation.isPending + const error = userQuery.error ?? actionMutation.error ?? groupsMutation.error + const run = (input: UserPoolUserAction) => { setNotice(''); actionMutation.mutate(input) } + const groupsChanged = selectedGroups.slice().sort().join('\n') !== currentGroups.slice().sort().join('\n') + const name = stringValue(user?.email) || username + const attributes = Object.entries((user?.attributes ?? {}) as Record) + const disabled = !runtimeReachable || pending + + return
+
+

User

{name}
+ +
+ {userQuery.isLoading &&

Loading user

} + {user &&
+
+
Status
{stringValue(user.status) || '—'}
+
Enabled
{user.enabled === false ? 'No' : 'Yes'}
+
Email verified
{user.emailVerified ? 'Yes' : 'No'}
+
Created
{formatDateTime(userQuery.data?.timestamp ?? null) ?? '—'}
+
Username
{username}
+
+ +
+

Actions

+
+ {user.enabled === false + ? + : } + {user.status === 'UNCONFIRMED' && } + + +
+
{ event.preventDefault(); if (password) run({action: 'setPassword', password, permanent}) }}> + + + +
+
+ +
+

Groups

+ {groups.length === 0 &&

This pool has no groups. Create one in Groups above.

} + {groups.map((group) => ( + + ))} + {groups.length > 0 && } +
+ + {attributes.length > 0 &&
+

Attributes

+ {attributes.sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ( + + ))}
{key}{value}
+
} +
} + {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} +
+} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} diff --git a/packages/frontend/src/components/CognitoUsersPanel.test.tsx b/packages/frontend/src/components/CognitoUsersPanel.test.tsx index d716492..6ecbbaf 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.test.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.test.tsx @@ -1,5 +1,5 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query' -import {render, screen} from '@testing-library/react' +import {render, screen, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {beforeEach, expect, test, vi} from 'vitest' import {CognitoUsersPanel} from './CognitoUsersPanel' @@ -9,7 +9,14 @@ const api = vi.hoisted(() => ({ deleteFlatItem: vi.fn(), getCloudResource: vi.fn(), listFlatItems: vi.fn(), + listUserPoolGroups: vi.fn(), putFlatItem: vi.fn(), + getFlatItem: vi.fn(), + updateUserPoolUser: vi.fn(), + setUserPoolUserGroups: vi.fn(), + createUserPoolGroup: vi.fn(), + deleteUserPoolGroup: vi.fn(), + testUserPoolSignIn: vi.fn(), })) vi.mock('@/api/cloudProxyClient', () => api) @@ -39,6 +46,11 @@ beforeEach(() => { api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null}) api.putFlatItem.mockResolvedValue(alice) api.deleteFlatItem.mockResolvedValue(undefined) + api.listUserPoolGroups.mockResolvedValue([{name: 'admins', description: 'Full access', precedence: 0, createdAt: null}]) + api.getFlatItem.mockResolvedValue({...alice, body: {...alice.body, emailVerified: true, groups: [], attributes: {sub: alice.id, email: 'alice@example.com'}}}) + api.updateUserPoolUser.mockResolvedValue(undefined) + api.setUserPoolUserGroups.mockResolvedValue(undefined) + api.createUserPoolGroup.mockResolvedValue({name: 'editors', description: null, precedence: null, createdAt: null}) }) test('shows the app client id and the pool users', async () => { @@ -58,9 +70,10 @@ test('creates an email user who can sign in immediately', async () => { 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'})) + const form = screen.getByRole('form', {name: 'Create user'}) + await user.type(within(form).getByLabelText('Email'), 'bob@example.com') + await user.type(within(form).getByLabelText('Password'), 'Passw0rd!') + await user.click(within(form).getByRole('button', {name: 'Create user'})) expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true}) }) @@ -70,10 +83,12 @@ test('creates a username user with a temporary password when asked', async () => 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'})) + await screen.findByText('a8e5c7d38d3b4a1a97c9d822de') + const form = screen.getByRole('form', {name: 'Create user'}) + await user.type(within(form).getByLabelText('Username'), 'bob') + await user.type(within(form).getByLabelText('Password'), 'Passw0rd!') + await user.click(within(form).getByLabelText(/User can sign in now/)) + await user.click(within(form).getByRole('button', {name: 'Create user'})) expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false}) }) @@ -87,3 +102,52 @@ test('deletes a user by its stored username', async () => { expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id) }) + +test('manages a user: disables it and puts it in a group', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + await user.click(await screen.findByRole('button', {name: 'Manage user alice@example.com'})) + await user.click(await screen.findByRole('button', {name: 'Disable'})) + expect(api.updateUserPoolUser).toHaveBeenCalledWith('aws', summary.id, alice.id, {action: 'disable'}) + expect(await screen.findByText(/User disabled/)).toBeInTheDocument() + + const groupBox = screen.getByRole('checkbox', {name: 'admins'}) + await user.click(groupBox) + await user.click(screen.getByRole('button', {name: 'Save groups'})) + expect(api.setUserPoolUserGroups).toHaveBeenCalledWith('aws', summary.id, alice.id, ['admins']) +}) + +test('creates a group with its precedence', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + renderPanel() + + await user.type(await screen.findByLabelText('Group name'), 'editors') + await user.type(screen.getByLabelText(/^Precedence/), '5') + await user.click(screen.getByRole('button', {name: 'Create group'})) + + expect(api.createUserPoolGroup).toHaveBeenCalledWith('aws', summary.id, {name: 'editors', precedence: 5}) +}) + +test('test sign-in shows the decoded claims, or why Cognito refused', async () => { + const user = userEvent.setup() + api.getCloudResource.mockResolvedValue(pool('email')) + api.testUserPoolSignIn + .mockResolvedValueOnce({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}) + .mockResolvedValueOnce({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: alice.id, email: 'alice@example.com', 'cognito:groups': ['admins']}, accessToken: {sub: alice.id, client_id: 'a8e5c7d38d3b4a1a97c9d822de'}}) + renderPanel() + + await screen.findByText('a8e5c7d38d3b4a1a97c9d822de') + const form = screen.getByRole('form', {name: 'Test sign-in'}) + await user.type(within(form).getByLabelText('Email'), 'alice@example.com') + await user.type(within(form).getByLabelText('Password'), 'wrong') + await user.click(within(form).getByRole('button', {name: 'Try sign-in'})) + expect(await screen.findByText('Incorrect username or password.')).toBeInTheDocument() + + await user.click(within(form).getByRole('button', {name: 'Try sign-in'})) + expect(await screen.findByText(/valid for 60 minutes/)).toBeInTheDocument() + expect(screen.getByText('cognito:groups', {selector: 'th code'})).toBeInTheDocument() + expect(api.testUserPoolSignIn).toHaveBeenLastCalledWith('aws', summary.id, {clientId: 'a8e5c7d38d3b4a1a97c9d822de', username: 'alice@example.com', password: 'wrong'}) +}) diff --git a/packages/frontend/src/components/CognitoUsersPanel.tsx b/packages/frontend/src/components/CognitoUsersPanel.tsx index bb6fcb5..1ef2c70 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.tsx @@ -1,7 +1,10 @@ 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 {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient' +import {CognitoGroupsSection} from '@/components/CognitoGroupsSection' +import {CognitoSignInTest} from '@/components/CognitoSignInTest' +import {CognitoUserDetail} from '@/components/CognitoUserDetail' import {CopyValueButton} from '@/components/CopyValueButton' import {useAccountId} from '@/lib/accountStore' import {formatDateTime} from '@/lib/format' @@ -20,7 +23,9 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { const [login, setLogin] = useState('') const [password, setPassword] = useState('') const [permanent, setPermanent] = useState(true) + const [openUser, setOpenUser] = useState(null) const usersKey = ['cognito-users', accountId, resource?.id] + const groupsKey = ['cognito-groups', accountId, resource?.id] // The table row is a list summary; sign-in method and app clients come from the full pool. const poolQuery = useQuery({ @@ -33,13 +38,25 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal), enabled: Boolean(resource) && runtimeReachable, }) + const groupsQuery = useQuery({ + queryKey: groupsKey, + queryFn: ({signal}) => listUserPoolGroups('aws', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) useEffect(() => { setLogin('') setPassword('') setPermanent(true) + setOpenUser(null) }, [resource?.id]) + // The pool list carries each pool's user count, which the delete confirmation reads. + const refreshUsers = () => Promise.all([ + queryClient.invalidateQueries({queryKey: usersKey}), + queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'userpools']}), + ]) + const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username' const createMutation = useMutation({ mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', { @@ -50,12 +67,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { onSuccess: () => { setLogin('') setPassword('') - return queryClient.invalidateQueries({queryKey: usersKey}) + return refreshUsers() }, }) const deleteMutation = useMutation({ mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username), - onSuccess: () => queryClient.invalidateQueries({queryKey: usersKey}), + onSuccess: (_result, username) => { + if (openUser === username) setOpenUser(null) + return refreshUsers() + }, }) if (!resource) { @@ -77,11 +97,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { {client.clientName || '—'}{client.clientId} ))}} + + + +
-

Users

{users.length} users in {resource.name}
- +

Users

{users.length} {users.length === 1 ? 'user' : 'users'} in {resource.name}
+
-
{ event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}> + { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}> @@ -97,9 +121,10 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { {stringValue(user.body.status) || '—'} {user.body.enabled === false ? 'No' : 'Yes'} {formatDateTime(user.timestamp) ?? '—'} - + })}} + {openUser && setOpenUser(null)}/>} } diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index c8c201d..b2c418f 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser"; import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel"; import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel"; import { NetworkingPanel } from "@/components/NetworkingPanel"; +import { describeDeleteImpact } from "@/lib/deleteImpact"; import { getPath } from "@/lib/resourcePath"; import { capabilityEnabled, @@ -574,12 +575,14 @@ export function DynamicResourceView({ const resources = resourcesQuery.data ?? []; const canCreate = schema.actions.includes("create"); const canDelete = schema.actions.includes("delete"); + const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems); const isAllSelected = resources.length > 0 && resources.every((r) => selectedItems.some((s) => s.id === r.id)); + // Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot. const activeSelected = selected?.cloud === cloud && selected.service === service - ? selected + ? resources.find((resource) => resource.id === selected.id) ?? selected : undefined; const runtimeReachable = cloudStatus?.runtime === "reachable"; const resourceCapabilityInputs = @@ -744,6 +747,9 @@ export function DynamicResourceView({ {canDelete && ( deleteConfirm ? ( <> + {deleteImpactText && ( + {deleteImpactText} + )}