143 lines
8.9 KiB
TypeScript
143 lines
8.9 KiB
TypeScript
import {RefreshCw, Trash2} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {useEffect, useState} from 'react'
|
|
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
|
|
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
|
|
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
|
|
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
|
|
import {CognitoUserDetail} from '@/components/CognitoUserDetail'
|
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
|
import {useAccountId} from '@/lib/accountStore'
|
|
import {formatDateTime} from '@/lib/format'
|
|
import type {CloudResource} from '@/types/resource'
|
|
|
|
type Props = {
|
|
resource?: CloudResource
|
|
runtimeReachable: boolean
|
|
}
|
|
|
|
type AppClient = {clientId: string; clientName: string}
|
|
|
|
export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
|
const accountId = useAccountId()
|
|
const queryClient = useQueryClient()
|
|
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({
|
|
queryKey: ['cognito-pool', accountId, resource?.id],
|
|
queryFn: ({signal}) => getCloudResource('aws', 'userpools', resource?.id ?? '', signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
const usersQuery = useQuery({
|
|
queryKey: usersKey,
|
|
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 ?? '', {
|
|
...(signInWithEmail ? {email: login.trim()} : {username: login.trim()}),
|
|
password,
|
|
permanent,
|
|
}),
|
|
onSuccess: () => {
|
|
setLogin('')
|
|
setPassword('')
|
|
return refreshUsers()
|
|
},
|
|
})
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
|
|
onSuccess: (_result, username) => {
|
|
if (openUser === username) setOpenUser(null)
|
|
return refreshUsers()
|
|
},
|
|
})
|
|
|
|
if (!resource) {
|
|
return <section className="table-panel"><div className="empty compact"><h3>Select a user pool</h3><p>Choose a user pool to manage its app clients and users.</p></div></section>
|
|
}
|
|
|
|
const users = usersQuery.data?.items ?? []
|
|
const appClients = appClientsOf(poolQuery.data)
|
|
const pending = createMutation.isPending || deleteMutation.isPending
|
|
const error = poolQuery.error ?? usersQuery.error ?? createMutation.error ?? deleteMutation.error
|
|
const loginLabel = signInWithEmail ? 'Email' : 'Username'
|
|
|
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Users in ${resource.name}`}>
|
|
<div className="input-row resource-table-bar">
|
|
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
|
|
</div>
|
|
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
|
|
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => (
|
|
<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>}
|
|
|
|
<CognitoTriggersSection pool={poolQuery.data ?? undefined}/>
|
|
|
|
<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.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" 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>
|
|
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !login.trim() || !password || pending}>{createMutation.isPending ? 'Creating' : 'Create user'}</button></div>
|
|
</form>
|
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
|
{usersQuery.isLoading && <div className="muted padded">Loading users</div>}
|
|
{usersQuery.isSuccess && users.length === 0 && <div className="empty compact"><h3>No users</h3><p>Create a user to sign in to apps that use this pool.</p></div>}
|
|
{users.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>{loginLabel}</th><th>Status</th><th>Enabled</th><th>Created</th><th/></tr></thead><tbody>{users.map((user) => {
|
|
const name = stringValue(user.body.email) || stringValue(user.body.username)
|
|
return <tr key={user.id}>
|
|
<td><span className="copy-value">{name}<CopyValueButton value={name} label={loginLabel}/></span></td>
|
|
<td><code>{stringValue(user.body.status) || '—'}</code></td>
|
|
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
|
<td>{formatDateTime(user.timestamp) ?? '—'}</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>
|
|
}
|
|
|
|
function appClientsOf(pool?: CloudResource): AppClient[] {
|
|
const clients = pool?.metadata.appClients
|
|
if (!Array.isArray(clients)) return []
|
|
return clients.filter((client): client is AppClient => typeof client?.clientId === 'string' && client.clientId !== '')
|
|
}
|
|
|
|
function stringValue(value: unknown): string {
|
|
return typeof value === 'string' ? value : ''
|
|
}
|