146 lines
8.2 KiB
TypeScript
146 lines
8.2 KiB
TypeScript
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)
|
|
}
|