Files
floci-ui/packages/frontend/src/components/CognitoUserDetail.tsx
T
Zakaria bafe799d00
CI / checks (pull_request) Failing after 12m24s
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.
2026-10-23 17:54:15 -04:00

138 lines
7.8 KiB
TypeScript

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<UserPoolUserAction['action'], string> = {
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<string[]>([])
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<string, string>)
const disabled = !runtimeReachable || pending
return <section className="cognito-user" aria-label={`User ${name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">User</p><strong>{name}</strong></div>
<button className="icon-btn" type="button" title="Close" aria-label="Close user details" onClick={onClose}><X size={14}/></button>
</div>
{userQuery.isLoading && <p className="muted padded">Loading user</p>}
{user && <div className="cognito-user-grid">
<dl className="cognito-facts">
<dt>Status</dt><dd><code>{stringValue(user.status) || '—'}</code></dd>
<dt>Enabled</dt><dd>{user.enabled === false ? 'No' : 'Yes'}</dd>
<dt>Email verified</dt><dd>{user.emailVerified ? 'Yes' : 'No'}</dd>
<dt>Created</dt><dd>{formatDateTime(userQuery.data?.timestamp ?? null) ?? '—'}</dd>
<dt>Username</dt><dd className="mono">{username}</dd>
</dl>
<div>
<p className="eyebrow">Actions</p>
<div className="cognito-actions">
{user.enabled === false
? <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'enable'})}>Enable</button>
: <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'disable'})}>Disable</button>}
{user.status === 'UNCONFIRMED' && <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'confirm'})}>Confirm sign-up</button>}
<button className="button" type="button" disabled={disabled} onClick={() => run({action: 'signOut'})}>Sign out everywhere</button>
<button className="button" type="button" disabled={disabled} title="Cognito sends the user a reset code" onClick={() => run({action: 'resetPassword'})}>Reset password</button>
</div>
<form className="cognito-set-password" onSubmit={(event) => { event.preventDefault(); if (password) run({action: 'setPassword', password, permanent}) }}>
<label className="dynamic-field"><span>New password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={disabled}/></label>
<label className="cognito-check"><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={disabled}/> User can sign in with it now</label>
<button className="button" type="submit" disabled={disabled || !password}>Set password</button>
</form>
</div>
<div>
<p className="eyebrow">Groups</p>
{groups.length === 0 && <p className="muted">This pool has no groups. Create one in Groups above.</p>}
{groups.map((group) => (
<label key={group.name} className="cognito-check">
<input type="checkbox" checked={selectedGroups.includes(group.name)} disabled={disabled} onChange={(event) => setSelectedGroups((current) => event.target.checked ? [...current, group.name] : current.filter((value) => value !== group.name))}/>
<code>{group.name}</code>
</label>
))}
{groups.length > 0 && <button className="button" type="button" disabled={disabled || !groupsChanged} onClick={() => { setNotice(''); groupsMutation.mutate() }}>{groupsMutation.isPending ? 'Saving' : 'Save groups'}</button>}
</div>
{attributes.length > 0 && <div className="cognito-attributes">
<p className="eyebrow">Attributes</p>
<table className="table"><tbody>{attributes.sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => (
<tr key={key}><th scope="row"><code>{key}</code></th><td className="mono">{value}</td></tr>
))}</tbody></table>
</div>}
</div>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
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 : ''
}