feat(frontend): hosted sign-in and OAuth settings for Cognito app clients
CI / checks (pull_request) Successful in 26m7s

This commit is contained in:
Zakaria
2026-10-24 11:16:46 -04:00
parent d411f632c9
commit 238bb1a3d5
16 changed files with 522 additions and 3 deletions
+18
View File
@@ -139,6 +139,10 @@ export const apiEndpointKeys = {
delete: "clouds.services.userpools.groups.delete",
},
signInTest: "clouds.services.userpools.sign-in-test",
client: {
get: "clouds.services.userpools.clients.get",
updateOAuth: "clouds.services.userpools.clients.update-oauth",
},
},
k8s: {
nodegroups: {
@@ -882,6 +886,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[
apiEndpointKeys.clouds.userpools.client.get,
{
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId",
method: "GET",
},
],
[
apiEndpointKeys.clouds.userpools.client.updateOAuth,
{
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId/oauth",
method: "PUT",
},
],
[
apiEndpointKeys.clouds.k8s.nodegroups.list,
{
@@ -31,6 +31,8 @@ import type {
UserPoolGroup,
UserPoolSignInTestInput,
UserPoolSignInTestResult,
UserPoolClientSettings,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
@@ -1474,6 +1476,34 @@ export async function testUserPoolSignIn(
return res.data;
}
export async function describeUserPoolClient(
cloud: CloudProvider,
poolId: string,
clientId: string,
signal?: AbortSignal,
): Promise<UserPoolClientSettings> {
const res = await apiClient.call<UserPoolClientSettings>(
apiEndpointKeys.clouds.userpools.client.get,
requestOptions(cloud, "userpools", { signal }),
{ cloud, id: poolId, clientId },
);
return res.data;
}
export async function updateUserPoolClientOAuth(
cloud: CloudProvider,
poolId: string,
clientId: string,
input: UpdateUserPoolClientOAuthInput,
): Promise<UserPoolClientSettings> {
const res = await apiClient.call<UserPoolClientSettings, UpdateUserPoolClientOAuthInput>(
apiEndpointKeys.clouds.userpools.client.updateOAuth,
requestOptions(cloud, "userpools", { body: input }),
{ cloud, id: poolId, clientId },
);
return res.data;
}
export async function putFlatItem(
cloud: CloudProvider,
service: CloudServiceType,
@@ -0,0 +1,145 @@
import {X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {describeUserPoolClient, updateUserPoolClientOAuth} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {UserPoolClientSettings, UserPoolOAuthFlow} from '@/types/resource'
type Props = {
poolId: string
clientId: string
runtimeReachable: boolean
onClose: () => void
}
const FLOWS: {flow: UserPoolOAuthFlow; label: string; hint: string}[] = [
{flow: 'code', label: 'Authorization code', hint: 'Recommended for web and mobile apps, with PKCE.'},
{flow: 'implicit', label: 'Implicit', hint: 'Legacy: tokens come back in the URL.'},
{flow: 'client_credentials', label: 'Client credentials', hint: 'Machine-to-machine. Needs a client secret.'},
]
const STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin']
const COGNITO = 'COGNITO'
type Draft = {callbackUrls: string; logoutUrls: string; oauthFlows: UserPoolOAuthFlow[]; oauthScopes: string[]; identityProviders: string[]}
export function CognitoAppClientSettings({poolId, clientId, runtimeReachable, onClose}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const clientKey = ['cognito-client', accountId, poolId, clientId]
const [draft, setDraft] = useState<Draft | null>(null)
const [notice, setNotice] = useState('')
const clientQuery = useQuery({
queryKey: clientKey,
queryFn: ({signal}) => describeUserPoolClient('aws', poolId, clientId, signal),
enabled: runtimeReachable,
})
const client = clientQuery.data
useEffect(() => {
if (client) setDraft(draftFrom(client))
}, [client])
const saveMutation = useMutation({
mutationFn: (value: Draft) => updateUserPoolClientOAuth('aws', poolId, clientId, {
callbackUrls: lines(value.callbackUrls),
logoutUrls: lines(value.logoutUrls),
oauthFlows: value.oauthFlows,
oauthScopes: value.oauthScopes,
identityProviders: value.identityProviders,
}),
onSuccess: (saved) => {
queryClient.setQueryData(clientKey, saved)
setNotice(saved.oauthFlows.includes('code') && saved.identityProviders.includes(COGNITO)
? 'Saved. Apps using this client can now send users to the hosted login page.'
: 'Saved.')
},
})
const disabled = !runtimeReachable || saveMutation.isPending
const scopes = draft ? [...STANDARD_SCOPES, ...draft.oauthScopes.filter((scope) => !STANDARD_SCOPES.includes(scope))] : STANDARD_SCOPES
const otherProviders = draft?.identityProviders.filter((provider) => provider !== COGNITO) ?? []
const changed = Boolean(client && draft && JSON.stringify(normalized(draft)) !== JSON.stringify(normalized(draftFrom(client))))
const update = (patch: Partial<Draft>) => { setNotice(''); setDraft((current) => current && {...current, ...patch}) }
const toggle = <T extends string>(list: T[], value: T, on: boolean) => on ? [...list.filter((entry) => entry !== value), value] : list.filter((entry) => entry !== value)
const error = clientQuery.error ?? saveMutation.error
return <section className="cognito-user" aria-label={`App client ${client?.clientName || clientId}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">App client</p><strong>{client?.clientName || clientId}</strong></div>
<button className="icon-btn" type="button" title="Close" aria-label="Close app client settings" onClick={onClose}><X size={14}/></button>
</div>
{clientQuery.isLoading && <p className="muted padded">Loading app client</p>}
{client && draft && <form className="cognito-user-grid" aria-label="Hosted sign-in settings" onSubmit={(event) => { event.preventDefault(); saveMutation.mutate(draft) }}>
<dl className="cognito-facts">
<dt>Client ID</dt><dd className="mono">{client.clientId}</dd>
<dt>Client secret</dt><dd>{client.hasSecret ? 'Yes' : 'No (public client)'}</dd>
<dt>Sign-in flows</dt><dd>{client.explicitAuthFlows.length ? client.explicitAuthFlows.map((flow) => <code key={flow} className="cognito-flow">{flow.replace(/^ALLOW_/, '')}</code>) : '—'}</dd>
</dl>
<div>
<p className="eyebrow">Hosted sign-in</p>
<label className="cognito-check">
<input type="checkbox" checked={draft.identityProviders.includes(COGNITO)} disabled={disabled} onChange={(event) => update({identityProviders: toggle(draft.identityProviders, COGNITO, event.target.checked)})}/>
Hosted login page for this pool's users
</label>
{otherProviders.length > 0 && <p className="muted compact-text">Also signs in with: {otherProviders.map((provider) => <code key={provider}>{provider}</code>)}</p>}
<p className="eyebrow">OAuth flows</p>
{FLOWS.map(({flow, label, hint}) => (
<label key={flow} className="cognito-check" title={hint}>
<input type="checkbox" checked={draft.oauthFlows.includes(flow)} disabled={disabled || (flow === 'client_credentials' && !client.hasSecret)} onChange={(event) => update({oauthFlows: toggle(draft.oauthFlows, flow, event.target.checked)})}/>
{label} <small className="muted">{hint}</small>
</label>
))}
</div>
<div>
<p className="eyebrow">Scopes</p>
{scopes.map((scope) => (
<label key={scope} className="cognito-check">
<input type="checkbox" checked={draft.oauthScopes.includes(scope)} disabled={disabled} onChange={(event) => update({oauthScopes: toggle(draft.oauthScopes, scope, event.target.checked)})}/>
<code>{scope}</code>
</label>
))}
</div>
<div className="cognito-client-urls">
<label className="dynamic-field"><span>Callback URLs</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.callbackUrls} disabled={disabled} placeholder="http://localhost:3000/api/auth/callback" onChange={(event) => update({callbackUrls: event.target.value})}/>
<small className="muted">One per line. Where Cognito sends users back with a code. https, or http for localhost.</small>
</label>
<label className="dynamic-field"><span>Sign-out URLs</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.logoutUrls} disabled={disabled} placeholder="http://localhost:3000/" onChange={(event) => update({logoutUrls: event.target.value})}/>
<small className="muted">One per line. Allowed <code>logout_uri</code> values after sign-out.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{saveMutation.isPending ? 'Saving' : 'Save settings'}</button></div>
</div>
</form>}
{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 draftFrom(client: UserPoolClientSettings): Draft {
return {
callbackUrls: client.callbackUrls.join('\n'),
logoutUrls: client.logoutUrls.join('\n'),
oauthFlows: client.oauthFlows,
oauthScopes: client.oauthScopes,
identityProviders: client.identityProviders,
}
}
function normalized(draft: Draft) {
return {
callbackUrls: lines(draft.callbackUrls),
logoutUrls: lines(draft.logoutUrls),
oauthFlows: [...draft.oauthFlows].sort(),
oauthScopes: [...draft.oauthScopes].sort(),
identityProviders: [...draft.identityProviders].sort(),
}
}
function lines(value: string): string[] {
return value.split('\n').map((line) => line.trim()).filter(Boolean)
}
@@ -17,6 +17,8 @@ const api = vi.hoisted(() => ({
createUserPoolGroup: vi.fn(),
deleteUserPoolGroup: vi.fn(),
testUserPoolSignIn: vi.fn(),
describeUserPoolClient: vi.fn(),
updateUserPoolClientOAuth: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
@@ -82,6 +84,32 @@ test('says when a pool has no Lambda triggers', async () => {
expect(await screen.findByText('This pool runs no Lambda triggers.')).toBeInTheDocument()
})
test('turns on hosted sign-in for an app client', async () => {
const user = userEvent.setup()
const settings = {clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client', hasSecret: false, explicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH'],
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
api.getCloudResource.mockResolvedValue(pool('email'))
api.describeUserPoolClient.mockResolvedValue(settings)
api.updateUserPoolClientOAuth.mockImplementation(async (_cloud, _pool, _client, input) => ({...settings, ...input}))
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Manage app client orders-users-client'}))
const form = await screen.findByRole('form', {name: 'Hosted sign-in settings'})
expect(within(form).getByRole('checkbox', {name: /Client credentials/})).toBeDisabled()
await user.click(within(form).getByRole('checkbox', {name: /Authorization code/}))
await user.click(within(form).getByRole('checkbox', {name: 'openid'}))
await user.click(within(form).getByRole('checkbox', {name: 'email'}))
await user.type(within(form).getByLabelText(/Callback URLs/), 'http://localhost:4600/api/auth/callback')
await user.type(within(form).getByLabelText(/Sign-out URLs/), 'http://localhost:4600/')
await user.click(within(form).getByRole('button', {name: 'Save settings'}))
expect(api.updateUserPoolClientOAuth).toHaveBeenCalledWith('aws', summary.id, 'a8e5c7d38d3b4a1a97c9d822de', {
callbackUrls: ['http://localhost:4600/api/auth/callback'], logoutUrls: ['http://localhost:4600/'],
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
})
expect(await screen.findByText(/can now send users to the hosted login page/)).toBeInTheDocument()
})
test('creates an email user who can sign in immediately', async () => {
const user = userEvent.setup()
api.getCloudResource.mockResolvedValue(pool('email'))
@@ -2,6 +2,7 @@ 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 {CognitoAppClientSettings} from '@/components/CognitoAppClientSettings'
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
@@ -25,6 +26,7 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
const [password, setPassword] = useState('')
const [permanent, setPermanent] = useState(true)
const [openUser, setOpenUser] = useState<string | null>(null)
const [openClient, setOpenClient] = useState<string | null>(null)
const usersKey = ['cognito-users', accountId, resource?.id]
const groupsKey = ['cognito-groups', accountId, resource?.id]
@@ -94,9 +96,14 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
<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>
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th><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>
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openClient === client.clientId} aria-label={`Manage app client ${client.clientName || client.clientId}`} onClick={() => setOpenClient(openClient === client.clientId ? null : client.clientId)}>Manage</button></td>
</tr>
))}</tbody></table></div>}
{openClient && <CognitoAppClientSettings key={openClient} poolId={resource.id} clientId={openClient} runtimeReachable={runtimeReachable} onClose={() => setOpenClient(null)}/>}
<CognitoTriggersSection pool={poolQuery.data ?? undefined}/>
+15
View File
@@ -4395,6 +4395,21 @@ pre,
margin-bottom: 8px;
}
.cognito-check small { flex-basis: 100%; padding-left: 24px; margin-top: -4px; }
.cognito-check:has(small) { flex-wrap: wrap; row-gap: 0; }
.cognito-flow { display: inline-block; margin: 0 6px 4px 0; }
.cognito-client-urls {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
align-items: start;
}
.cognito-client-urls > div { align-self: end; }
.delete-impact {
color: var(--status-warn);
font-size: 13px;
+16
View File
@@ -302,6 +302,22 @@ export interface UserPoolSignInTestInput {
password: string
}
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
export interface UserPoolClientSettings {
clientId: string
clientName: string
hasSecret: boolean
explicitAuthFlows: string[]
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export type UpdateUserPoolClientOAuthInput = Pick<UserPoolClientSettings, 'callbackUrls' | 'logoutUrls' | 'oauthFlows' | 'oauthScopes' | 'identityProviders'>
export type UserPoolSignInTestResult =
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
| {outcome: 'challenge'; challengeName: string}
+1 -1
View File
@@ -27,7 +27,7 @@ export type KubernetesActionName =
| 'createFargateProfile'
| 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {