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:
@@ -130,6 +130,16 @@ export const apiEndpointKeys = {
|
|||||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||||
moveTasks: "clouds.services.messaging.move-tasks",
|
moveTasks: "clouds.services.messaging.move-tasks",
|
||||||
},
|
},
|
||||||
|
userpools: {
|
||||||
|
userAction: "clouds.services.userpools.users.action",
|
||||||
|
userGroups: "clouds.services.userpools.users.groups",
|
||||||
|
groups: {
|
||||||
|
list: "clouds.services.userpools.groups.list",
|
||||||
|
create: "clouds.services.userpools.groups.create",
|
||||||
|
delete: "clouds.services.userpools.groups.delete",
|
||||||
|
},
|
||||||
|
signInTest: "clouds.services.userpools.sign-in-test",
|
||||||
|
},
|
||||||
k8s: {
|
k8s: {
|
||||||
nodegroups: {
|
nodegroups: {
|
||||||
list: "clouds.services.k8s.nodegroups.list",
|
list: "clouds.services.k8s.nodegroups.list",
|
||||||
@@ -154,6 +164,7 @@ export const apiEndpointKeys = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
flatItems: {
|
flatItems: {
|
||||||
|
get: "clouds.services.flatItems.get",
|
||||||
list: "clouds.services.flatItems.list",
|
list: "clouds.services.flatItems.list",
|
||||||
query: "clouds.services.flatItems.query",
|
query: "clouds.services.flatItems.query",
|
||||||
create: "clouds.services.flatItems.create",
|
create: "clouds.services.flatItems.create",
|
||||||
@@ -829,6 +840,48 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.userAction,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/actions",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.userGroups,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/groups",
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.list,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.create,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/groups/:groupName",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.signInTest,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/sign-in-test",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||||
{
|
{
|
||||||
@@ -998,6 +1051,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.flatItems.get,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.flatItems.list,
|
apiEndpointKeys.clouds.flatItems.list,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import type {
|
|||||||
DatabaseSnapshot,
|
DatabaseSnapshot,
|
||||||
KubernetesFargateProfile,
|
KubernetesFargateProfile,
|
||||||
KubernetesNodegroup,
|
KubernetesNodegroup,
|
||||||
|
CreateUserPoolGroupInput,
|
||||||
|
UserPoolGroup,
|
||||||
|
UserPoolSignInTestInput,
|
||||||
|
UserPoolSignInTestResult,
|
||||||
|
UserPoolUserAction,
|
||||||
LogsInsightsQueryInput,
|
LogsInsightsQueryInput,
|
||||||
LogsInsightsQueryResult,
|
LogsInsightsQueryResult,
|
||||||
LogsFilterInput,
|
LogsFilterInput,
|
||||||
@@ -1377,6 +1382,98 @@ export async function deleteFlatItem(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFlatItem(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
service: CloudServiceType,
|
||||||
|
resourceId: string,
|
||||||
|
itemId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChildItem> {
|
||||||
|
const res = await apiClient.call<ChildItem>(
|
||||||
|
apiEndpointKeys.clouds.flatItems.get,
|
||||||
|
requestOptions(cloud, service, { signal }),
|
||||||
|
{ cloud, service, id: resourceId, itemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserPoolUser(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
username: string,
|
||||||
|
input: UserPoolUserAction,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, UserPoolUserAction>(
|
||||||
|
apiEndpointKeys.clouds.userpools.userAction,
|
||||||
|
requestOptions(cloud, "userpools", { body: input }),
|
||||||
|
{ cloud, id: poolId, username },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserPoolUserGroups(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
username: string,
|
||||||
|
groups: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, { groups: string[] }>(
|
||||||
|
apiEndpointKeys.clouds.userpools.userGroups,
|
||||||
|
requestOptions(cloud, "userpools", { body: { groups } }),
|
||||||
|
{ cloud, id: poolId, username },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserPoolGroups(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<UserPoolGroup[]> {
|
||||||
|
const res = await apiClient.call<UserPoolGroup[]>(
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.list,
|
||||||
|
requestOptions(cloud, "userpools", { signal }),
|
||||||
|
{ cloud, id: poolId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserPoolGroup(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
input: CreateUserPoolGroupInput,
|
||||||
|
): Promise<UserPoolGroup> {
|
||||||
|
const res = await apiClient.call<UserPoolGroup, CreateUserPoolGroupInput>(
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.create,
|
||||||
|
requestOptions(cloud, "userpools", { body: input }),
|
||||||
|
{ cloud, id: poolId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserPoolGroup(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
groupName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||||
|
requestOptions(cloud, "userpools"),
|
||||||
|
{ cloud, id: poolId, groupName },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testUserPoolSignIn(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
input: UserPoolSignInTestInput,
|
||||||
|
): Promise<UserPoolSignInTestResult> {
|
||||||
|
const res = await apiClient.call<UserPoolSignInTestResult, UserPoolSignInTestInput>(
|
||||||
|
apiEndpointKeys.clouds.userpools.signInTest,
|
||||||
|
requestOptions(cloud, "userpools", { body: input }),
|
||||||
|
{ cloud, id: poolId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function putFlatItem(
|
export async function putFlatItem(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
service: CloudServiceType,
|
service: CloudServiceType,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {Trash2} from 'lucide-react'
|
||||||
|
import {useMutation, useQueryClient, type UseQueryResult} from '@tanstack/react-query'
|
||||||
|
import {useState} from 'react'
|
||||||
|
import {createUserPoolGroup, deleteUserPoolGroup} from '@/api/cloudProxyClient'
|
||||||
|
import type {UserPoolGroup} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
poolId: string
|
||||||
|
groupsQuery: UseQueryResult<UserPoolGroup[]>
|
||||||
|
groupsKey: unknown[]
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CognitoGroupsSection({poolId, groupsQuery, groupsKey, runtimeReachable}: Props) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [precedence, setPrecedence] = useState('')
|
||||||
|
const refresh = () => queryClient.invalidateQueries({queryKey: groupsKey})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createUserPoolGroup('aws', poolId, {
|
||||||
|
name: name.trim(),
|
||||||
|
...(description.trim() ? {description: description.trim()} : {}),
|
||||||
|
...(precedence.trim() ? {precedence: Number(precedence)} : {}),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName('')
|
||||||
|
setDescription('')
|
||||||
|
setPrecedence('')
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (groupName: string) => deleteUserPoolGroup('aws', poolId, groupName),
|
||||||
|
onSuccess: refresh,
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = groupsQuery.data ?? []
|
||||||
|
const pending = createMutation.isPending || deleteMutation.isPending
|
||||||
|
const error = groupsQuery.error ?? createMutation.error ?? deleteMutation.error
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Groups</p><span className="muted">Group names appear in the <code>cognito:groups</code> token claim, so apps can check roles</span></div>
|
||||||
|
</div>
|
||||||
|
<form className="dynamic-form" aria-label="Create group" onSubmit={(event) => { event.preventDefault(); if (name.trim()) createMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field"><span>Group name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="admins" disabled={pending} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(event) => setDescription(event.target.value)} disabled={pending}/></label>
|
||||||
|
<label className="dynamic-field"><span>Precedence</span><input className="input" type="number" min={0} step={1} value={precedence} onChange={(event) => setPrecedence(event.target.value)} disabled={pending}/><small className="muted">Lower wins when a user is in several groups.</small></label>
|
||||||
|
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !name.trim() || pending}>{createMutation.isPending ? 'Creating' : 'Create group'}</button></div>
|
||||||
|
</form>
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
{groupsQuery.isSuccess && groups.length === 0 && <p className="muted padded">No groups yet.</p>}
|
||||||
|
{groups.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Description</th><th>Precedence</th><th/></tr></thead><tbody>{groups.map((group) => (
|
||||||
|
<tr key={group.name}>
|
||||||
|
<td><code>{group.name}</code></td>
|
||||||
|
<td>{group.description || '—'}</td>
|
||||||
|
<td>{group.precedence ?? '—'}</td>
|
||||||
|
<td><button className="icon-btn danger" type="button" title="Delete group" aria-label={`Delete group ${group.name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(group.name)}><Trash2 size={14}/></button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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 : ''
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
import {render, screen} from '@testing-library/react'
|
import {render, screen, within} from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import {beforeEach, expect, test, vi} from 'vitest'
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
import {CognitoUsersPanel} from './CognitoUsersPanel'
|
import {CognitoUsersPanel} from './CognitoUsersPanel'
|
||||||
@@ -9,7 +9,14 @@ const api = vi.hoisted(() => ({
|
|||||||
deleteFlatItem: vi.fn(),
|
deleteFlatItem: vi.fn(),
|
||||||
getCloudResource: vi.fn(),
|
getCloudResource: vi.fn(),
|
||||||
listFlatItems: vi.fn(),
|
listFlatItems: vi.fn(),
|
||||||
|
listUserPoolGroups: vi.fn(),
|
||||||
putFlatItem: vi.fn(),
|
putFlatItem: vi.fn(),
|
||||||
|
getFlatItem: vi.fn(),
|
||||||
|
updateUserPoolUser: vi.fn(),
|
||||||
|
setUserPoolUserGroups: vi.fn(),
|
||||||
|
createUserPoolGroup: vi.fn(),
|
||||||
|
deleteUserPoolGroup: vi.fn(),
|
||||||
|
testUserPoolSignIn: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
@@ -39,6 +46,11 @@ beforeEach(() => {
|
|||||||
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
|
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
|
||||||
api.putFlatItem.mockResolvedValue(alice)
|
api.putFlatItem.mockResolvedValue(alice)
|
||||||
api.deleteFlatItem.mockResolvedValue(undefined)
|
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||||
|
api.listUserPoolGroups.mockResolvedValue([{name: 'admins', description: 'Full access', precedence: 0, createdAt: null}])
|
||||||
|
api.getFlatItem.mockResolvedValue({...alice, body: {...alice.body, emailVerified: true, groups: [], attributes: {sub: alice.id, email: 'alice@example.com'}}})
|
||||||
|
api.updateUserPoolUser.mockResolvedValue(undefined)
|
||||||
|
api.setUserPoolUserGroups.mockResolvedValue(undefined)
|
||||||
|
api.createUserPoolGroup.mockResolvedValue({name: 'editors', description: null, precedence: null, createdAt: null})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows the app client id and the pool users', async () => {
|
test('shows the app client id and the pool users', async () => {
|
||||||
@@ -58,9 +70,10 @@ test('creates an email user who can sign in immediately', async () => {
|
|||||||
renderPanel()
|
renderPanel()
|
||||||
|
|
||||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||||
await user.type(screen.getByLabelText('Email'), 'bob@example.com')
|
const form = screen.getByRole('form', {name: 'Create user'})
|
||||||
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
|
await user.type(within(form).getByLabelText('Email'), 'bob@example.com')
|
||||||
await user.click(screen.getByRole('button', {name: 'Create user'}))
|
await user.type(within(form).getByLabelText('Password'), 'Passw0rd!')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create user'}))
|
||||||
|
|
||||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true})
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true})
|
||||||
})
|
})
|
||||||
@@ -70,10 +83,12 @@ test('creates a username user with a temporary password when asked', async () =>
|
|||||||
api.getCloudResource.mockResolvedValue(pool('username'))
|
api.getCloudResource.mockResolvedValue(pool('username'))
|
||||||
renderPanel()
|
renderPanel()
|
||||||
|
|
||||||
await user.type(await screen.findByLabelText('Username'), 'bob')
|
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||||
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
|
const form = screen.getByRole('form', {name: 'Create user'})
|
||||||
await user.click(screen.getByLabelText(/User can sign in now/))
|
await user.type(within(form).getByLabelText('Username'), 'bob')
|
||||||
await user.click(screen.getByRole('button', {name: 'Create user'}))
|
await user.type(within(form).getByLabelText('Password'), 'Passw0rd!')
|
||||||
|
await user.click(within(form).getByLabelText(/User can sign in now/))
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create user'}))
|
||||||
|
|
||||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||||
})
|
})
|
||||||
@@ -87,3 +102,52 @@ test('deletes a user by its stored username', async () => {
|
|||||||
|
|
||||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id)
|
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('manages a user: disables it and puts it in a group', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Manage user alice@example.com'}))
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Disable'}))
|
||||||
|
expect(api.updateUserPoolUser).toHaveBeenCalledWith('aws', summary.id, alice.id, {action: 'disable'})
|
||||||
|
expect(await screen.findByText(/User disabled/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
const groupBox = screen.getByRole('checkbox', {name: 'admins'})
|
||||||
|
await user.click(groupBox)
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Save groups'}))
|
||||||
|
expect(api.setUserPoolUserGroups).toHaveBeenCalledWith('aws', summary.id, alice.id, ['admins'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a group with its precedence', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.type(await screen.findByLabelText('Group name'), 'editors')
|
||||||
|
await user.type(screen.getByLabelText(/^Precedence/), '5')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Create group'}))
|
||||||
|
|
||||||
|
expect(api.createUserPoolGroup).toHaveBeenCalledWith('aws', summary.id, {name: 'editors', precedence: 5})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test sign-in shows the decoded claims, or why Cognito refused', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
api.testUserPoolSignIn
|
||||||
|
.mockResolvedValueOnce({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
|
||||||
|
.mockResolvedValueOnce({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: alice.id, email: 'alice@example.com', 'cognito:groups': ['admins']}, accessToken: {sub: alice.id, client_id: 'a8e5c7d38d3b4a1a97c9d822de'}})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||||
|
const form = screen.getByRole('form', {name: 'Test sign-in'})
|
||||||
|
await user.type(within(form).getByLabelText('Email'), 'alice@example.com')
|
||||||
|
await user.type(within(form).getByLabelText('Password'), 'wrong')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Try sign-in'}))
|
||||||
|
expect(await screen.findByText('Incorrect username or password.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Try sign-in'}))
|
||||||
|
expect(await screen.findByText(/valid for 60 minutes/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('cognito:groups', {selector: 'th code'})).toBeInTheDocument()
|
||||||
|
expect(api.testUserPoolSignIn).toHaveBeenLastCalledWith('aws', summary.id, {clientId: 'a8e5c7d38d3b4a1a97c9d822de', username: 'alice@example.com', password: 'wrong'})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {RefreshCw, Trash2} from 'lucide-react'
|
import {RefreshCw, Trash2} from 'lucide-react'
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
import {useEffect, useState} from 'react'
|
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 {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
import {useAccountId} from '@/lib/accountStore'
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
import {formatDateTime} from '@/lib/format'
|
import {formatDateTime} from '@/lib/format'
|
||||||
@@ -20,7 +23,9 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
|||||||
const [login, setLogin] = useState('')
|
const [login, setLogin] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [permanent, setPermanent] = useState(true)
|
const [permanent, setPermanent] = useState(true)
|
||||||
|
const [openUser, setOpenUser] = useState<string | null>(null)
|
||||||
const usersKey = ['cognito-users', accountId, resource?.id]
|
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.
|
// The table row is a list summary; sign-in method and app clients come from the full pool.
|
||||||
const poolQuery = useQuery({
|
const poolQuery = useQuery({
|
||||||
@@ -33,13 +38,25 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
|||||||
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
|
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
|
||||||
enabled: Boolean(resource) && runtimeReachable,
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
})
|
})
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: groupsKey,
|
||||||
|
queryFn: ({signal}) => listUserPoolGroups('aws', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogin('')
|
setLogin('')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setPermanent(true)
|
setPermanent(true)
|
||||||
|
setOpenUser(null)
|
||||||
}, [resource?.id])
|
}, [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 signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username'
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
|
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
|
||||||
@@ -50,12 +67,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setLogin('')
|
setLogin('')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
return queryClient.invalidateQueries({queryKey: usersKey})
|
return refreshUsers()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
|
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) {
|
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>
|
<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>}
|
))}</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 className="input-row resource-table-bar">
|
||||||
<div><p className="eyebrow">Users</p><span className="muted">{users.length} users in {resource.name}</span></div>
|
<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()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
<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>
|
</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>{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"><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>
|
<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><code>{stringValue(user.body.status) || '—'}</code></td>
|
||||||
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
||||||
<td>{formatDateTime(user.timestamp) ?? '—'}</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>
|
</tr>
|
||||||
})}</tbody></table></div>}
|
})}</tbody></table></div>}
|
||||||
|
{openUser && <CognitoUserDetail key={openUser} poolId={resource.id} username={openUser} groups={groupsQuery.data ?? []} usersKey={usersKey} runtimeReachable={runtimeReachable} onClose={() => setOpenUser(null)}/>}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
|
|||||||
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
||||||
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
||||||
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
||||||
|
import { describeDeleteImpact } from "@/lib/deleteImpact";
|
||||||
import { getPath } from "@/lib/resourcePath";
|
import { getPath } from "@/lib/resourcePath";
|
||||||
import {
|
import {
|
||||||
capabilityEnabled,
|
capabilityEnabled,
|
||||||
@@ -574,12 +575,14 @@ export function DynamicResourceView({
|
|||||||
const resources = resourcesQuery.data ?? [];
|
const resources = resourcesQuery.data ?? [];
|
||||||
const canCreate = schema.actions.includes("create");
|
const canCreate = schema.actions.includes("create");
|
||||||
const canDelete = schema.actions.includes("delete");
|
const canDelete = schema.actions.includes("delete");
|
||||||
|
const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems);
|
||||||
const isAllSelected =
|
const isAllSelected =
|
||||||
resources.length > 0 &&
|
resources.length > 0 &&
|
||||||
resources.every((r) => selectedItems.some((s) => s.id === r.id));
|
resources.every((r) => selectedItems.some((s) => s.id === r.id));
|
||||||
|
// Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot.
|
||||||
const activeSelected =
|
const activeSelected =
|
||||||
selected?.cloud === cloud && selected.service === service
|
selected?.cloud === cloud && selected.service === service
|
||||||
? selected
|
? resources.find((resource) => resource.id === selected.id) ?? selected
|
||||||
: undefined;
|
: undefined;
|
||||||
const runtimeReachable = cloudStatus?.runtime === "reachable";
|
const runtimeReachable = cloudStatus?.runtime === "reachable";
|
||||||
const resourceCapabilityInputs =
|
const resourceCapabilityInputs =
|
||||||
@@ -744,6 +747,9 @@ export function DynamicResourceView({
|
|||||||
{canDelete && (
|
{canDelete && (
|
||||||
deleteConfirm ? (
|
deleteConfirm ? (
|
||||||
<>
|
<>
|
||||||
|
{deleteImpactText && (
|
||||||
|
<span className="delete-impact" role="status">{deleteImpactText}</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="button danger"
|
className="button danger"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {ResourceTable} from './ResourceTable'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import type {ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
|
const schema: ServiceSchema = {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'userpools',
|
||||||
|
displayName: 'Cognito',
|
||||||
|
fields: [],
|
||||||
|
actions: ['list', 'delete'],
|
||||||
|
filters: [],
|
||||||
|
columns: [{name: 'name', label: 'Name'}],
|
||||||
|
deleteImpact: [
|
||||||
|
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
|
||||||
|
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool: CloudResource = {
|
||||||
|
id: 'us-east-1_abc', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||||
|
region: 'us-east-1', createdAt: null, metadata: {userCount: 2, appClients: [{clientId: 'c1'}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the row delete confirmation says what else is deleted', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
render(<ResourceTable schema={schema} resources={[pool]} onSelect={vi.fn()} onDelete={onDelete}/>)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete orders-users'}))
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('This also deletes 2 users and 1 app client.')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Confirm'}))
|
||||||
|
expect(onDelete).toHaveBeenCalledWith(pool)
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {Pencil, Trash2} from 'lucide-react';
|
import {Pencil, Trash2} from 'lucide-react';
|
||||||
|
import {describeDeleteImpact} from '@/lib/deleteImpact';
|
||||||
import type {CloudResource} from '@/types/resource';
|
import type {CloudResource} from '@/types/resource';
|
||||||
import type {ServiceSchema} from '@/types/schema';
|
import type {ServiceSchema} from '@/types/schema';
|
||||||
import {getPath} from '@/lib/resourcePath';
|
import {getPath} from '@/lib/resourcePath';
|
||||||
@@ -140,6 +141,7 @@ export function ResourceTable({
|
|||||||
className="table-confirm-actions"
|
className="table-confirm-actions"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<DeleteImpact schema={schema} resource={resource} />
|
||||||
<button
|
<button
|
||||||
className="button danger compact"
|
className="button danger compact"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -207,3 +209,8 @@ function CopyableColumnValue({resource, column}: {resource: CloudResource; colum
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeleteImpact({schema, resource}: {schema: ServiceSchema; resource: CloudResource}) {
|
||||||
|
const text = describeDeleteImpact(schema.deleteImpact, [resource]);
|
||||||
|
return text ? <span className="delete-impact" role="status">{text}</span> : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4307,6 +4307,110 @@ pre,
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cognito-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-outcome {
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent-tint-faint);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-outcome p { margin: 0 0 12px; }
|
||||||
|
.cognito-outcome--ok { border-left-color: var(--status-ok); }
|
||||||
|
.cognito-outcome--challenge { border-left-color: var(--status-warn); }
|
||||||
|
.cognito-outcome--rejected { border-left-color: var(--status-error); }
|
||||||
|
|
||||||
|
.cognito-claims {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-claims .table th,
|
||||||
|
.cognito-attributes .table th {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-user {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
border: 1px solid var(--accent-tint-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-user-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-attributes { grid-column: 1 / -1; overflow-x: auto; }
|
||||||
|
|
||||||
|
.cognito-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-facts dt { color: var(--text-muted); }
|
||||||
|
.cognito-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.cognito-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-set-password {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cognito-set-password .dynamic-field { width: 100%; }
|
||||||
|
|
||||||
|
.cognito-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-impact {
|
||||||
|
color: var(--status-warn);
|
||||||
|
font-size: 13px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-confirm-actions:has(.delete-impact) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-confirm-actions .delete-impact {
|
||||||
|
flex-basis: 100%;
|
||||||
|
max-width: 220px;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.diagnostics {
|
.diagnostics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {expect, test} from 'vitest'
|
||||||
|
import {describeDeleteImpact} from './deleteImpact'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const impact = [
|
||||||
|
{path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'},
|
||||||
|
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||||
|
]
|
||||||
|
|
||||||
|
function pool(users: unknown, clients: unknown[]): CloudResource {
|
||||||
|
return {id: 'p', name: 'p', cloud: 'aws', service: 'userpools', type: 'user-pool', region: null, createdAt: null, metadata: {estimatedNumberOfUsers: users, appClients: clients}}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sums numbers and list lengths across the selection', () => {
|
||||||
|
expect(describeDeleteImpact(impact, [pool(2, [{}]), pool(1, [])])).toBe('This also deletes 3 users and 1 app client.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves out zero and unreadable counts', () => {
|
||||||
|
expect(describeDeleteImpact(impact, [pool(0, [{}, {}])])).toBe('This also deletes 2 app clients.')
|
||||||
|
expect(describeDeleteImpact(impact, [pool(null, [])])).toBeNull()
|
||||||
|
expect(describeDeleteImpact(undefined, [pool(5, [])])).toBeNull()
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {getPath} from '@/lib/resourcePath'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import type {ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "This also deletes 2 users and 1 app client." for the delete confirmation,
|
||||||
|
* summed over the selected resources. A count is a number at the path, or the
|
||||||
|
* length of a list there; anything else, and zero counts, are left out.
|
||||||
|
*/
|
||||||
|
export function describeDeleteImpact(impact: ServiceSchema['deleteImpact'], resources: CloudResource[]): string | null {
|
||||||
|
if (!impact?.length) return null
|
||||||
|
|
||||||
|
const parts = impact.flatMap(({path, singular, plural}) => {
|
||||||
|
const total = resources.reduce((sum, resource) => {
|
||||||
|
const value = getPath(resource, path)
|
||||||
|
if (Array.isArray(value)) return sum + value.length
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? sum + value : sum
|
||||||
|
}, 0)
|
||||||
|
return total > 0 ? [`${total} ${total === 1 ? singular : plural}`] : []
|
||||||
|
})
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
|
||||||
|
const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
|
||||||
|
return `This also deletes ${list}.`
|
||||||
|
}
|
||||||
@@ -274,3 +274,35 @@ export interface AppConfigDeployment {
|
|||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserPoolUserAction =
|
||||||
|
| {action: 'enable'}
|
||||||
|
| {action: 'disable'}
|
||||||
|
| {action: 'confirm'}
|
||||||
|
| {action: 'resetPassword'}
|
||||||
|
| {action: 'signOut'}
|
||||||
|
| {action: 'setPassword'; password: string; permanent?: boolean}
|
||||||
|
|
||||||
|
export interface UserPoolGroup {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
precedence: number | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserPoolGroupInput {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
precedence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPoolSignInTestInput {
|
||||||
|
clientId: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserPoolSignInTestResult =
|
||||||
|
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||||
|
| {outcome: 'challenge'; challengeName: string}
|
||||||
|
| {outcome: 'rejected'; code: string; message: string}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type KubernetesActionName =
|
|||||||
| 'createFargateProfile'
|
| 'createFargateProfile'
|
||||||
| 'deleteFargateProfile'
|
| 'deleteFargateProfile'
|
||||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||||
|
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
export interface CapabilitySchema<TAction extends string> {
|
export interface CapabilitySchema<TAction extends string> {
|
||||||
@@ -86,8 +87,11 @@ export interface ServiceSchema {
|
|||||||
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
|
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
|
||||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||||
|
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||||
}
|
}
|
||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
columns: TableColumnSchema[]
|
columns: TableColumnSchema[]
|
||||||
updateFields?: FieldSchema[]
|
updateFields?: FieldSchema[]
|
||||||
|
/** Counts read from the resource that the delete confirmation names. */
|
||||||
|
deleteImpact?: Array<{path: string; singular: string; plural: string}>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user