feat(frontend): manage Cognito users and groups, and test sign-in
CI / checks (pull_request) Failing after 12m24s
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:
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user