feat(frontend): manage Cognito users and groups, and test sign-in
CI / checks (pull_request) Failing after 12m24s

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.
This commit is contained in:
Zakaria
2026-10-23 17:54:15 -04:00
parent 43f4cae9b5
commit bafe799d00
15 changed files with 773 additions and 16 deletions
@@ -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<string | null>(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) {
<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>}
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
<CognitoGroupsSection poolId={resource.id} groupsQuery={groupsQuery} groupsKey={groupsKey} runtimeReachable={runtimeReachable}/>
<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><p className="eyebrow">Users</p><span className="muted">{users.length} {users.length === 1 ? 'user' : 'users'} in {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch(), groupsQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
<form className="dynamic-form" aria-label="Create user" 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>
@@ -97,9 +121,10 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
<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>
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openUser === user.id} aria-label={`Manage user ${name}`} onClick={() => setOpenUser(openUser === user.id ? null : user.id)}>Manage</button><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>}
{openUser && <CognitoUserDetail key={openUser} poolId={resource.id} username={openUser} groups={groupsQuery.data ?? []} usersKey={usersKey} runtimeReachable={runtimeReachable} onClose={() => setOpenUser(null)}/>}
</section>
}