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:
@@ -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 <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Test sign-in</p><span className="muted">Signs in the way an app does and shows what is inside the tokens</span></div>
|
||||
</div>
|
||||
<form className="dynamic-form" aria-label="Test sign-in" onSubmit={(event) => { event.preventDefault(); if (clientId && username.trim() && password) signIn.mutate() }}>
|
||||
<label className="dynamic-field"><span>App client</span><select className="input" value={clientId} onChange={(event) => setClientId(event.target.value)} disabled={signIn.isPending}>{appClients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}</select></label>
|
||||
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="off" disabled={signIn.isPending} required/></label>
|
||||
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="off" disabled={signIn.isPending} required/></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button" type="submit" disabled={!runtimeReachable || !clientId || !username.trim() || !password || signIn.isPending}>{signIn.isPending ? 'Signing in' : 'Try sign-in'}</button></div>
|
||||
</form>
|
||||
{signIn.error instanceof Error && <p className="error-text compact-text" role="alert">{signIn.error.message}</p>}
|
||||
{result?.outcome === 'rejected' && <p className="cognito-outcome cognito-outcome--rejected" role="status"><strong>Refused</strong> <code>{result.code}</code> {result.message}</p>}
|
||||
{result?.outcome === 'challenge' && <p className="cognito-outcome cognito-outcome--challenge" role="status"><strong>Cognito wants another step</strong> <code>{result.challengeName}</code> {challengeHint(result.challengeName)}</p>}
|
||||
{result?.outcome === 'signed-in' && <div className="cognito-outcome cognito-outcome--ok" role="status">
|
||||
<p><strong>Signed in.</strong> {result.expiresIn ? `The ID and access tokens are valid for ${Math.round(result.expiresIn / 60)} minutes.` : ''}</p>
|
||||
<div className="cognito-claims">
|
||||
<ClaimsTable title="ID token: who the user is" claims={result.idToken}/>
|
||||
<ClaimsTable title="Access token: what this app may do" claims={result.accessToken}/>
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
|
||||
function ClaimsTable({title, claims}: {title: string; claims: Record<string, unknown>}) {
|
||||
const keys = [...KEY_CLAIMS.filter((key) => key in claims), ...Object.keys(claims).filter((key) => !KEY_CLAIMS.includes(key)).sort()]
|
||||
return <div>
|
||||
<p className="eyebrow">{title}</p>
|
||||
<table className="table"><tbody>{keys.map((key) => (
|
||||
<tr key={key}><th scope="row"><code>{key}</code></th><td className="mono">{formatClaim(key, claims[key])}</td></tr>
|
||||
))}</tbody></table>
|
||||
</div>
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
Reference in New Issue
Block a user