Compare commits

..

10 Commits

Author SHA1 Message Date
Zakaria d457c08b12 feat(api): show the verification code in the SES mailbox
CI / checks (pull_request) Failing after 12m24s
2026-10-24 01:23:56 -04:00
Zakaria 4bc8da6ee1 Merge pull request 'feat(api): Cognito user actions, groups and test sign-in' (#9) from feat/cognito-round-2 into main
CI / checks (push) Failing after 12m34s
Reviewed-on: #9
2026-10-23 22:56:19 +00:00
Zakaria bafe799d00 feat(frontend): manage Cognito users and groups, and test sign-in
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.
2026-10-23 17:54:15 -04:00
Zakaria 43f4cae9b5 fix(api): count Cognito pool users directly
The runtime leaves EstimatedNumberOfUsers at 0, so the pool metadata now
carries a userCount from ListUsers (capped at 1000), which the delete
confirmation reads.
2026-10-23 17:54:15 -04:00
Zakaria b4c2cadf5c feat(api): Cognito user actions, groups and test sign-in
Adds enable, disable, confirm, sign-out, password reset and set-password
for one user; user group membership; group list, create and delete; and a
test sign-in that returns decoded token claims. A refused test sign-in is
an outcome rather than a 401, which would end the console session.

Also adds a generic GET for one flat item and a schema deleteImpact so
delete confirmations can say what else goes with a resource.
2026-10-23 17:38:26 -04:00
Zakaria 8803498498 Merge pull request 'feat(api): add Cognito user pools to the cloud explorer' (#8) from feat/cognito-user-pools into main
CI / checks (push) Failing after 12m33s
Reviewed-on: #8
2026-10-23 18:41:30 +00:00
Zakaria f6b9a62811 Merge pull request 'fix(frontend): share one theme setting between the login page and console' (#7) from chore/ui-quick-wins into main
CI / checks (push) Has been cancelled
Reviewed-on: #7
2026-10-23 18:41:01 +00:00
Zakaria 8b166d86d4 feat(frontend): collapse responding services on the diagnostics page
CI / checks (pull_request) Failing after 12m12s
Failing console services stay visible; responding ones are behind a toggle.
2026-10-23 12:00:10 -04:00
Zakaria f956d977ca fix(frontend): fit the top bar on phone-width screens
Below 640px the search shrinks, the account switcher shows only its icon, and the connection indicator collapses to its status dot with the label kept for screen readers.
2026-10-23 12:00:10 -04:00
Zakaria 9f38313e59 fix(frontend): share one theme setting between the login page and console
The login page stored its theme under thecloud-theme while the console read floci-theme, so a choice made at sign-in was lost. Both now use floci-theme (falling back to the old key once), and useTheme tolerates unavailable storage.
2026-10-23 12:00:10 -04:00
32 changed files with 1492 additions and 34 deletions
+4 -2
View File
@@ -1,4 +1,6 @@
(() => {
// Shared with the console (useTheme.ts, index.html) so one choice applies everywhere.
const THEME_KEY = 'floci-theme';
const root = document.documentElement;
const buttons = document.querySelectorAll('.tg button');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -6,7 +8,7 @@
function setTheme(theme) {
root.setAttribute('data-theme', theme);
try {
localStorage.setItem('thecloud-theme', theme);
localStorage.setItem(THEME_KEY, theme);
} catch (ignored) {
// Theme persistence is optional when browser storage is unavailable.
}
@@ -15,7 +17,7 @@
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
try {
const savedTheme = localStorage.getItem('thecloud-theme');
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
initialTheme = savedTheme;
}
@@ -46,6 +46,19 @@ describe('AwsSesAdapter', () => {
})])
})
test('picks out the code from verification emails', async () => {
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
messages: [
{Id: 'cognito', Subject: 'Your verification code', Body: {text_part: 'Your verification code is 869472.'}},
{Id: 'html', Subject: 'Reset', Body: {html_part: '<p>Your confirmation code is: <b>4410</b></p>'}},
{Id: 'plain', Subject: 'Invoice', Body: {text_part: 'Order 123456 shipped.'}},
],
}))
const codes = (await adapter.list()).map((resource) => resource.metadata.code)
expect(codes).toEqual(['869472', '4410', null])
})
test('filters messages by subject, sender, and recipients', async () => {
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
messages: [
@@ -125,10 +125,18 @@ function messageToResource(message: FlociSesMessage): CloudResource {
htmlBody: message.Body?.html_part ?? null,
rawData: message.RawData ?? null,
messageType: raw ? 'raw' : 'simple',
code: findCode(message.Body?.text_part ?? message.Body?.html_part),
},
}
}
// Matches the wording of Cognito's default and most custom messages: "Your verification code is 123456."
const CODE_PATTERN = /\bcode\s+is:?\s*(?:<[^>]+>\s*)*(\d{4,8})\b/i
function findCode(body: string | null | undefined): string | null {
return body?.match(CODE_PATTERN)?.[1] ?? null
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
@@ -1,5 +1,17 @@
import {createHmac} from 'node:crypto'
import {describe, expect, test} from 'bun:test'
import {
AdminAddUserToGroupCommand,
AdminConfirmSignUpCommand,
AdminDisableUserCommand,
AdminListGroupsForUserCommand,
AdminRemoveUserFromGroupCommand,
AdminUserGlobalSignOutCommand,
CreateGroupCommand,
DeleteGroupCommand,
DescribeUserPoolClientCommand,
InitiateAuthCommand,
ListGroupsCommand,
AdminCreateUserCommand,
AdminDeleteUserCommand,
AdminGetUserCommand,
@@ -14,7 +26,7 @@ import {
type CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider'
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
import {ValidationError} from '../cloud-spi/errors'
import {ConflictError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
@@ -109,12 +121,25 @@ describe('AwsUserPoolsAdapter', () => {
signInWith: 'email',
passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol',
estimatedNumberOfUsers: 1,
userCount: 0,
mfaConfiguration: 'OFF',
deletionProtection: 'INACTIVE',
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
})
})
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
if (command instanceof ListUsersCommand) return command.input.PaginationToken ? {Users: [alice]} : {Users: [alice, alice], PaginationToken: 'page-2'}
return {}
})
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
expect(resource?.metadata.userCount).toBe(3)
expect(resource?.metadata.estimatedNumberOfUsers).toBe(0)
})
test('summarises a relaxed password policy without character rules', async () => {
const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}}
@@ -255,3 +280,110 @@ describe('AwsUserPoolsAdapter users', () => {
expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username})
})
})
function jwt(claims: Record<string, unknown>): string {
return ['{"alg":"RS256"}', JSON.stringify(claims), 'sig'].map((part) => Buffer.from(part).toString('base64url')).join('.')
}
describe('AwsUserPoolsAdapter user actions and groups', () => {
test('reads a user with the groups it belongs to', async () => {
const {client, sent} = stubCognito((command) => {
if (command instanceof AdminGetUserCommand) return {Username: alice.Username, UserAttributes: alice.Attributes, UserStatus: 'CONFIRMED', Enabled: true, UserCreateDate: CREATED}
if (command instanceof AdminListGroupsForUserCommand) return {Groups: [{GroupName: 'admins'}]}
return {}
})
const user = await new AwsUserPoolsAdapter(client).items.getItem!(POOL_ID, alice.Username)
expect(user?.body.groups).toEqual(['admins'])
expect((sent[1] as AdminListGroupsForUserCommand).input).toMatchObject({UserPoolId: POOL_ID, Username: alice.Username})
})
test('runs each user action as its Admin call', async () => {
const {client, sent} = stubCognito(() => ({}))
const adapter = new AwsUserPoolsAdapter(client)
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'disable'})
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'signOut'})
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword', password: ' Passw0rd! '})
expect(sent[0]).toBeInstanceOf(AdminDisableUserCommand)
expect(sent[1]).toBeInstanceOf(AdminConfirmSignUpCommand)
expect(sent[2]).toBeInstanceOf(AdminUserGlobalSignOutCommand)
expect((sent[3] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: 'bob', Password: ' Passw0rd! ', Permanent: true})
})
test('rejects unknown actions and reports a refused one as a conflict, never a 401', async () => {
const {client} = stubCognito(() => { throw awsError('NotAuthorizedException') })
const adapter = new AwsUserPoolsAdapter(client)
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'explode'} as never)).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword'} as never)).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})).rejects.toBeInstanceOf(ConflictError)
})
test('sets a user\'s groups by adding and removing only the difference', async () => {
const {client, sent} = stubCognito((command) => command instanceof AdminListGroupsForUserCommand ? {Groups: [{GroupName: 'staff'}, {GroupName: 'beta'}]} : {})
await new AwsUserPoolsAdapter(client).setUserPoolUserGroups(POOL_ID, 'bob', ['staff', 'admins'])
const changes = sent.slice(1)
expect(changes).toHaveLength(2)
expect(changes[0]).toBeInstanceOf(AdminAddUserToGroupCommand)
expect((changes[0] as AdminAddUserToGroupCommand).input.GroupName).toBe('admins')
expect(changes[1]).toBeInstanceOf(AdminRemoveUserFromGroupCommand)
expect((changes[1] as AdminRemoveUserFromGroupCommand).input.GroupName).toBe('beta')
})
test('lists, creates and deletes groups', async () => {
const {client, sent} = stubCognito((command) => {
if (command instanceof ListGroupsCommand) return {Groups: [{GroupName: 'admins', Description: 'Full access', Precedence: 0, CreationDate: CREATED}]}
if (command instanceof CreateGroupCommand) return {Group: {GroupName: command.input.GroupName, Precedence: command.input.Precedence, CreationDate: CREATED}}
return {}
})
const adapter = new AwsUserPoolsAdapter(client)
expect(await adapter.listUserPoolGroups(POOL_ID)).toEqual([{name: 'admins', description: 'Full access', precedence: 0, createdAt: CREATED.toISOString()}])
expect(await adapter.createUserPoolGroup(POOL_ID, {name: 'editors', precedence: 5})).toMatchObject({name: 'editors', precedence: 5})
await adapter.deleteUserPoolGroup(POOL_ID, 'editors')
expect((sent[1] as CreateGroupCommand).input).toEqual({UserPoolId: POOL_ID, GroupName: 'editors', Precedence: 5})
expect(sent[2]).toBeInstanceOf(DeleteGroupCommand)
})
test('validates a group before creating it', async () => {
const {client, sent} = stubCognito(() => ({}))
const adapter = new AwsUserPoolsAdapter(client)
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'has space'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'ok', precedence: -1})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('test sign-in returns decoded claims, adding SECRET_HASH for a client with a secret', async () => {
const {client, sent} = stubCognito((command) => {
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: {ClientId: 'client-1', ClientSecret: 'shh'}}
if (command instanceof InitiateAuthCommand) return {AuthenticationResult: {ExpiresIn: 3600, IdToken: jwt({sub: 's-1', email: 'alice@example.com', token_use: 'id'}), AccessToken: jwt({sub: 's-1', token_use: 'access'})}}
return {}
})
const result = await new AwsUserPoolsAdapter(client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'alice@example.com', password: 'Passw0rd!'})
expect(result).toEqual({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: 's-1', email: 'alice@example.com', token_use: 'id'}, accessToken: {sub: 's-1', token_use: 'access'}})
const auth = (sent[1] as InitiateAuthCommand).input
expect(auth.AuthFlow).toBe('USER_PASSWORD_AUTH')
expect(auth.AuthParameters?.SECRET_HASH).toBe(createHmac('sha256', 'shh').update('alice@example.comclient-1').digest('base64'))
})
test('test sign-in reports a wrong password or a challenge as its outcome', async () => {
const refusing = stubCognito((command) => {
if (command instanceof InitiateAuthCommand) throw Object.assign(awsError('NotAuthorizedException'), {message: 'Incorrect username or password.'})
return {UserPoolClient: {ClientId: 'client-1'}}
})
expect(await new AwsUserPoolsAdapter(refusing.client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'a', password: 'x'}))
.toEqual({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
expect((refusing.sent[1] as InitiateAuthCommand).input.AuthParameters?.SECRET_HASH).toBeUndefined()
const challenging = stubCognito((command) => command instanceof InitiateAuthCommand ? {ChallengeName: 'NEW_PASSWORD_REQUIRED'} : {UserPoolClient: {}})
expect(await new AwsUserPoolsAdapter(challenging.client).testUserPoolSignIn(POOL_ID, {clientId: 'c', username: 'a', password: 'x'}))
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
})
})
@@ -1,17 +1,32 @@
import {createHmac} from 'node:crypto'
import {
AdminAddUserToGroupCommand,
AdminConfirmSignUpCommand,
AdminCreateUserCommand,
AdminDeleteUserCommand,
AdminDisableUserCommand,
AdminEnableUserCommand,
AdminGetUserCommand,
AdminListGroupsForUserCommand,
AdminRemoveUserFromGroupCommand,
AdminResetUserPasswordCommand,
AdminSetUserPasswordCommand,
AdminUserGlobalSignOutCommand,
CreateGroupCommand,
CreateUserPoolClientCommand,
CreateUserPoolCommand,
DeleteGroupCommand,
DeleteUserPoolCommand,
DescribeUserPoolClientCommand,
DescribeUserPoolCommand,
InitiateAuthCommand,
ListGroupsCommand,
ListUserPoolClientsCommand,
ListUserPoolsCommand,
ListUsersCommand,
type AttributeType,
type CognitoIdentityProviderClient,
type GroupType,
type PasswordPolicyType,
type UserPoolClientDescription,
type UserPoolDescriptionType,
@@ -19,8 +34,9 @@ import {
type UserType,
} from '@aws-sdk/client-cognito-identity-provider'
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
USER_POOL_GROUP_NAME_PATTERN,
USER_POOL_NAME_PATTERN,
USER_POOL_PASSWORD_POLICIES,
USER_POOL_SIGN_IN,
@@ -30,12 +46,19 @@ import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
CreateUserPoolGroupInput,
ResourceQuery,
ServiceSchema,
UserPoolGroup,
UserPoolSignInTestInput,
UserPoolSignInTestResult,
UserPoolUserAction,
} from '../cloud-spi/types'
/** Cognito caps every list page at 60. */
const COGNITO_PAGE_LIMIT = 60
/** Counting stops here so a very large pool cannot stall the pool list. */
const USER_COUNT_CAP = 1000
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
@@ -51,6 +74,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
* symbol, matching AWS.
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
* password is permanent, so users created here can sign in immediately.
* - `EstimatedNumberOfUsers` stays 0 however many users exist (AWS also only
* updates it periodically), so `userCount` is counted with `ListUsers`.
* - An admin action the user's state does not allow (confirming a confirmed
* user) is `NotAuthorizedException`, surfaced here as a conflict because the
* console caller is authorised and a 401 would end the console session.
*
* Users are the pool's child items. The app client is created with the pool, as
* the console's create flow does, and its id is surfaced in the pool metadata.
@@ -86,11 +114,12 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
async get(id: string): Promise<CloudResource | null> {
try {
const [pool, clients] = await Promise.all([
const [pool, clients, userCount] = await Promise.all([
this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})),
this.listClients(id),
this.countUsers(id),
])
return pool.UserPool ? toResource(pool.UserPool, clients) : null
return pool.UserPool ? toResource(pool.UserPool, clients, userCount) : null
} catch (error) {
if (isMissing(error)) return null
throw error
@@ -140,6 +169,137 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id}))
}
async updateUserPoolUser(poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
const target = {UserPoolId: poolId, Username: username}
const action = (input as {action?: unknown} | null)?.action
const command = (() => {
switch (action) {
case 'enable': return new AdminEnableUserCommand(target)
case 'disable': return new AdminDisableUserCommand(target)
case 'confirm': return new AdminConfirmSignUpCommand(target)
case 'resetPassword': return new AdminResetUserPasswordCommand(target)
case 'signOut': return new AdminUserGlobalSignOutCommand(target)
case 'setPassword': {
const {password, permanent} = input as {password?: unknown; permanent?: unknown}
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
if (permanent !== undefined && typeof permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
return new AdminSetUserPasswordCommand({...target, Password: password, Permanent: permanent ?? true})
}
default:
throw new ValidationError('action must be one of enable, disable, confirm, resetPassword, signOut, setPassword')
}
})()
try {
await this.cognito.send(command as AdminEnableUserCommand)
} catch (error) {
throw asConflict(error)
}
}
/** Makes the user's groups exactly `groups`, adding and removing only the difference. */
async setUserPoolUserGroups(poolId: string, username: string, groups: string[]): Promise<void> {
if (!Array.isArray(groups) || groups.some((group) => typeof group !== 'string' || !group)) {
throw new ValidationError('groups must be a list of group names')
}
const wanted = new Set(groups)
const current = new Set(await this.listGroupsForUser(poolId, username))
for (const group of wanted) {
if (!current.has(group)) await this.cognito.send(new AdminAddUserToGroupCommand({UserPoolId: poolId, Username: username, GroupName: group}))
}
for (const group of current) {
if (!wanted.has(group)) await this.cognito.send(new AdminRemoveUserFromGroupCommand({UserPoolId: poolId, Username: username, GroupName: group}))
}
}
async listUserPoolGroups(poolId: string): Promise<UserPoolGroup[]> {
const groups: GroupType[] = []
let nextToken: string | undefined
do {
const res = await this.cognito.send(new ListGroupsCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
groups.push(...(res.Groups ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
return groups.flatMap(toGroup)
}
async createUserPoolGroup(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> {
const name = requiredString(input?.name, 'name')
if (!new RegExp(USER_POOL_GROUP_NAME_PATTERN, 'u').test(name)) throw new ValidationError('name must be up to 128 characters with no spaces')
const description = optionalString(input.description, 'description')
if (description && description.length > 2048) throw new ValidationError('description must be 2048 characters or fewer')
const precedence = input.precedence
if (precedence !== undefined && precedence !== null && (!Number.isInteger(precedence) || precedence < 0)) {
throw new ValidationError('precedence must be a whole number of 0 or more')
}
const res = await this.cognito.send(new CreateGroupCommand({
UserPoolId: poolId,
GroupName: name,
...(description ? {Description: description} : {}),
...(typeof precedence === 'number' ? {Precedence: precedence} : {}),
}))
const group = res.Group ? toGroup(res.Group)[0] : undefined
if (!group) throw new RuntimeError(`Cognito did not return the group ${name} created in ${poolId}`)
return group
}
async deleteUserPoolGroup(poolId: string, groupName: string): Promise<void> {
await this.cognito.send(new DeleteGroupCommand({UserPoolId: poolId, GroupName: groupName}))
}
/**
* Signs in the way an app would (`InitiateAuth`, `USER_PASSWORD_AUTH`) and
* returns the decoded claims. A refused sign-in is a result, not an error:
* the wrong password is exactly what this is for trying.
*/
async testUserPoolSignIn(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> {
const clientId = requiredString(input?.clientId, 'clientId')
const username = requiredString(input.username, 'username')
if (typeof input.password !== 'string' || !input.password) throw new ValidationError('password is required')
const client = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: clientId}))
const secret = client.UserPoolClient?.ClientSecret
try {
const res = await this.cognito.send(new InitiateAuthCommand({
ClientId: clientId,
AuthFlow: 'USER_PASSWORD_AUTH',
AuthParameters: {
USERNAME: username,
PASSWORD: input.password,
...(secret ? {SECRET_HASH: secretHash(username, clientId, secret)} : {}),
},
}))
if (res.ChallengeName) return {outcome: 'challenge', challengeName: res.ChallengeName}
const result = res.AuthenticationResult
if (!result?.IdToken || !result.AccessToken) throw new RuntimeError('Cognito returned no tokens')
return {
outcome: 'signed-in',
expiresIn: result.ExpiresIn ?? null,
idToken: decodeJwtPayload(result.IdToken),
accessToken: decodeJwtPayload(result.AccessToken),
}
} catch (error) {
const name = (error as {name?: string} | null)?.name ?? ''
if (SIGN_IN_REFUSALS.has(name)) return {outcome: 'rejected', code: name, message: (error as Error).message}
throw error
}
}
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
return listGroupNamesForUser(this.cognito, poolId, username)
}
private async countUsers(poolId: string): Promise<number> {
let count = 0
let token: string | undefined
do {
const res = await this.cognito.send(new ListUsersCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(token ? {PaginationToken: token} : {})}))
count += res.Users?.length ?? 0
token = res.PaginationToken || undefined
} while (token && count < USER_COUNT_CAP)
return Math.min(count, USER_COUNT_CAP)
}
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
const clients: UserPoolClientDescription[] = []
let nextToken: string | undefined
@@ -169,7 +329,9 @@ class UserStore implements ItemStoreAdapter {
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
try {
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
return toUserItem(user.Username, user.UserAttributes, user)[0] ?? null
const item = toUserItem(user.Username, user.UserAttributes, user)[0]
if (!item) return null
return {...item, body: {...item.body, groups: await listGroupNamesForUser(this.cognito, poolId, item.id)}}
} catch (error) {
if (isMissing(error)) return null
throw error
@@ -215,6 +377,53 @@ class UserStore implements ItemStoreAdapter {
}
}
/** Refusals a test sign-in reports as its outcome rather than failing. */
const SIGN_IN_REFUSALS = new Set([
'NotAuthorizedException',
'UserNotFoundException',
'UserNotConfirmedException',
'PasswordResetRequiredException',
'InvalidParameterException',
])
async function listGroupNamesForUser(cognito: CognitoIdentityProviderClient, poolId: string, username: string): Promise<string[]> {
const names: string[] = []
let nextToken: string | undefined
do {
const res = await cognito.send(new AdminListGroupsForUserCommand({UserPoolId: poolId, Username: username, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
names.push(...(res.Groups ?? []).flatMap((group) => group.GroupName ? [group.GroupName] : []))
nextToken = res.NextToken || undefined
} while (nextToken)
return names
}
function toGroup(group: GroupType): UserPoolGroup[] {
if (!group.GroupName) return []
return [{
name: group.GroupName,
description: group.Description ?? null,
precedence: group.Precedence ?? null,
createdAt: group.CreationDate ? group.CreationDate.toISOString() : null,
}]
}
/** Required by app clients that have a secret: Base64(HMAC-SHA256(secret, username + clientId)). */
function secretHash(username: string, clientId: string, secret: string): string {
return createHmac('sha256', secret).update(username + clientId).digest('base64')
}
/** The token came straight from Cognito, so only its payload is read; nothing is trusted from it. */
function decodeJwtPayload(token: string): Record<string, unknown> {
const payload = token.split('.')[1]
if (!payload) throw new RuntimeError('Cognito returned a malformed token')
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>
}
function asConflict(error: unknown): unknown {
if ((error as {name?: string} | null)?.name !== 'NotAuthorizedException') return error
return new ConflictError((error as Error).message || 'The user is not in a state that allows this')
}
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
if (!pool.Id) return []
return [{
@@ -230,7 +439,7 @@ function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
}]
}
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource {
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], userCount: number): CloudResource {
const id = pool.Id ?? ''
const policy = pool.Policies?.PasswordPolicy
return {
@@ -247,6 +456,7 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): C
signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username',
passwordPolicy: policy ? describePasswordPolicy(policy) : null,
estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null,
userCount,
mfaConfiguration: pool.MfaConfiguration ?? null,
deletionProtection: pool.DeletionProtection ?? null,
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
@@ -137,6 +137,7 @@ describe('cognito errors carry HTTP 400 and keep their meaning', () => {
['UserNotFoundException', 404],
['UsernameExistsException', 409],
['AliasExistsException', 409],
['GroupExistsException', 409],
['InvalidPasswordException', 400],
]
@@ -60,6 +60,7 @@ const CONFLICT_NAMES = new Set([
'DeleteConflictException',
'UsernameExistsException',
'AliasExistsException',
'GroupExistsException',
])
const ACCESS_DENIED_NAMES = new Set([
@@ -4,6 +4,7 @@ const emailColumns: TableColumnSchema[] = [
{name: 'name', label: 'Subject'},
{name: 'source', label: 'From', path: 'metadata.source', format: 'code'},
{name: 'to', label: 'To', path: 'metadata.toAddresses', format: 'list'},
{name: 'code', label: 'Code', path: 'metadata.code', format: 'code'},
{name: 'messageType', label: 'Type', path: 'metadata.messageType', format: 'badge'},
{name: 'createdAt', label: 'Captured At', format: 'datetime'},
]
+53
View File
@@ -171,6 +171,13 @@ export type KubernetesActionName =
| 'listFargateProfiles'
| 'createFargateProfile'
| 'deleteFargateProfile'
export type UserPoolActionName =
| 'updateUser'
| 'setUserGroups'
| 'listGroups'
| 'createGroup'
| 'deleteGroup'
| 'testSignIn'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -214,6 +221,7 @@ export interface ServiceSchema {
objectActions?: CapabilitySchema<ObjectActionName>[]
databaseActions?: CapabilitySchema<DatabaseActionName>[]
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
// Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event.
@@ -223,6 +231,11 @@ export interface ServiceSchema {
filters: FieldSchema[]
columns: TableColumnSchema[]
updateFields?: FieldSchema[]
/**
* Counts read from the resource (`path` as in columns) that the delete
* confirmation names, so deleting a pool says it also deletes its users.
*/
deleteImpact?: {path: string; singular: string; plural: string}[]
}
export type KnownResourceType =
@@ -411,6 +424,40 @@ export interface SqsMoveTaskResult {
taskHandle: string
}
/** What an admin can do to one user pool user, each a single Admin* call. */
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
}
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
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}
/** Provider-neutral representation of a managed Kubernetes node pool. */
export interface KubernetesNodegroup {
id: string
@@ -917,6 +964,12 @@ export interface CloudServiceAdapter {
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
updateUserPoolUser?(poolId: string, username: string, input: UserPoolUserAction): Promise<void>
setUserPoolUserGroups?(poolId: string, username: string, groups: string[]): Promise<void>
listUserPoolGroups?(poolId: string): Promise<UserPoolGroup[]>
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
/** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void>
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
@@ -4,6 +4,8 @@ import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from
export const USER_POOL_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$'
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] as const
/** Cognito's group name rule: 1–128 letters, marks, symbols, digits or punctuation (no whitespace). Needs the `u` flag. */
export const USER_POOL_GROUP_NAME_PATTERN = '^[\\p{L}\\p{M}\\p{S}\\p{N}\\p{P}]{1,128}$'
const userPoolColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
@@ -72,9 +74,21 @@ export function awsUserPoolsSchema(): ServiceSchema {
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
],
userPoolActions: [
{name: 'updateUser', label: 'Enable, disable, confirm, sign out or reset a user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setUserGroups', label: 'Change a user\'s groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'listGroups', label: 'List groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: userPoolFilters,
columns: userPoolColumns,
deleteImpact: [
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
],
}
}
+20 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName} from './cloud-spi/types'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -47,6 +47,15 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile',
}
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
updateUser: 'updateUserPoolUser',
setUserGroups: 'setUserPoolUserGroups',
listGroups: 'listUserPoolGroups',
createGroup: 'createUserPoolGroup',
deleteGroup: 'deleteUserPoolGroup',
testSignIn: 'testUserPoolSignIn',
}
const registry = createCloudAdapterRegistry()
function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> {
@@ -121,6 +130,15 @@ describe('registered adapters honour their schema', () => {
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const userPoolClaims = (adapter.schema().capabilities?.userPoolActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of userPoolClaims) {
const method = METHOD_FOR_USER_POOL_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
})
test(`${label} child collections match their advertised capabilities`, () => {
@@ -133,6 +151,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.objectActions ?? []),
...(adapter.schema().capabilities?.databaseActions ?? []),
...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []),
]
+53
View File
@@ -16,6 +16,7 @@ import {awsEksSchema} from '../cloud-spi/eksSchema'
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
@@ -694,6 +695,58 @@ describe('cloud schema routes', () => {
])
})
test('reads one flat item, answering 404 when it is gone', async () => {
const app = appWithRoutes([mockAdapter('aws', {
service: 'userpools',
schema: awsUserPoolsSchema,
items: {
listItems: async () => ({items: [], nextCursor: null}),
getItem: async (_poolId, username) => username === 'bob@example.com'
? {id: 'u-1', collectionId: null, timestamp: null, body: {email: username, groups: ['admins']}, metadata: {}}
: null,
},
})])
const found = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/bob%40example.com')
const missing = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/nobody')
expect(found.status).toBe(200)
expect((await found.json()).body.groups).toEqual(['admins'])
expect(missing.status).toBe(404)
})
test('routes Cognito user actions, groups and test sign-in to the adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'userpools',
schema: awsUserPoolsSchema,
updateUserPoolUser: async (poolId, username, input) => { calls.push(['action', poolId, username, input]) },
setUserPoolUserGroups: async (poolId, username, groups) => { calls.push(['groups', poolId, username, groups]) },
listUserPoolGroups: async () => [{name: 'admins', description: null, precedence: 0, createdAt: null}],
createUserPoolGroup: async (_poolId, input) => ({name: input.name, description: null, precedence: null, createdAt: null}),
deleteUserPoolGroup: async (poolId, groupName) => { calls.push(['deleteGroup', poolId, groupName]) },
testUserPoolSignIn: async () => ({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}),
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x'
expect((await app.request(`${base}/users/bob%40example.com/actions`, json('POST', {action: 'disable'}))).status).toBe(200)
expect((await app.request(`${base}/users/bob%40example.com/groups`, json('PUT', {groups: ['admins']}))).status).toBe(200)
expect(await (await app.request(`${base}/groups`)).json()).toEqual([{name: 'admins', description: null, precedence: 0, createdAt: null}])
expect((await app.request(`${base}/groups`, json('POST', {name: 'editors'}))).status).toBe(201)
expect((await app.request(`${base}/groups/editors`, {method: 'DELETE'})).status).toBe(200)
const signIn = await app.request(`${base}/sign-in-test`, json('POST', {clientId: 'c', username: 'bob', password: 'x'}))
expect(signIn.status).toBe(200)
expect((await signIn.json()).outcome).toBe('rejected')
expect(calls).toEqual([
['action', 'us-east-1_x', 'bob@example.com', {action: 'disable'}],
['groups', 'us-east-1_x', 'bob@example.com', ['admins']],
['deleteGroup', 'us-east-1_x', 'editors'],
])
})
test('clears the SES mailbox through the email adapter', async () => {
let cleared = false
const app = appWithRoutes([mockAdapter('aws', {
+65
View File
@@ -392,6 +392,61 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.post('/:cloud/services/userpools/resources/:id/users/:username/actions', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
await svc(c).updateUserPoolUser(cloud, c.req.param('id'), c.req.param('username'), await c.req.json())
return c.json({ok: true})
})
})
app.put('/:cloud/services/userpools/resources/:id/users/:username/groups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{groups?: string[]}>()
await svc(c).setUserPoolUserGroups(cloud, c.req.param('id'), c.req.param('username'), body.groups as string[])
return c.json({ok: true})
})
})
app.get('/:cloud/services/userpools/resources/:id/groups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listUserPoolGroups(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/userpools/resources/:id/groups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const group = await svc(c).createUserPoolGroup(cloud, c.req.param('id'), await c.req.json())
return c.json(group, 201)
})
})
app.delete('/:cloud/services/userpools/resources/:id/groups/:groupName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteUserPoolGroup(cloud, c.req.param('id'), c.req.param('groupName'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/userpools/resources/:id/sign-in-test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json())))
})
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -1157,6 +1212,16 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.get('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
const target = childTarget(c)
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const item = await svc(c).getFlatItem(target.cloud, target.service, c.req.param('id'), c.req.param('itemId'))
return item ? c.json(item) : c.json({error: 'Item not found'}, 404)
})
})
app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
const target = childTarget(c)
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
@@ -77,6 +77,11 @@ import type {
StorageObjectDownload,
StorageObjectList,
UpdateResourceInput,
CreateUserPoolGroupInput,
UserPoolGroup,
UserPoolSignInTestInput,
UserPoolSignInTestResult,
UserPoolUserAction,
} from '../cloud-spi/types'
import type {
ChildCollection,
@@ -664,6 +669,12 @@ export class CloudProxyService {
return this.requireItems(cloud, service).listItems(resourceId, page)
}
async getFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, itemId: string): Promise<ChildItem | null> {
const items = this.requireItems(cloud, service)
if (!items.getItem) throw new NotSupportedError(`Reading one item is not supported for ${cloud}/${service}`)
return items.getItem(resourceId, itemId)
}
async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
const items = this.requireItems(cloud, service)
if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${cloud}/${service}`)
@@ -784,6 +795,42 @@ export class CloudProxyService {
return adapter.filterLogEvents(logGroupName, input, page)
}
async updateUserPoolUser(cloud: CloudProvider, poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.updateUserPoolUser) throw new NotSupportedError(`User actions are not supported for ${cloud}/userpools`)
await adapter.updateUserPoolUser(poolId, username, input)
}
async setUserPoolUserGroups(cloud: CloudProvider, poolId: string, username: string, groups: string[]): Promise<void> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.setUserPoolUserGroups) throw new NotSupportedError(`User groups are not supported for ${cloud}/userpools`)
await adapter.setUserPoolUserGroups(poolId, username, groups)
}
async listUserPoolGroups(cloud: CloudProvider, poolId: string): Promise<UserPoolGroup[]> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.listUserPoolGroups) throw new NotSupportedError(`Groups are not supported for ${cloud}/userpools`)
return adapter.listUserPoolGroups(poolId)
}
async createUserPoolGroup(cloud: CloudProvider, poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.createUserPoolGroup) throw new NotSupportedError(`Group creation is not supported for ${cloud}/userpools`)
return adapter.createUserPoolGroup(poolId, input)
}
async deleteUserPoolGroup(cloud: CloudProvider, poolId: string, groupName: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.deleteUserPoolGroup) throw new NotSupportedError(`Group deletion is not supported for ${cloud}/userpools`)
await adapter.deleteUserPoolGroup(poolId, groupName)
}
async testUserPoolSignIn(cloud: CloudProvider, poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.testUserPoolSignIn) throw new NotSupportedError(`Test sign-in is not supported for ${cloud}/userpools`)
return adapter.testUserPoolSignIn(poolId, input)
}
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
const adapter = this.requireAdapter(cloud, 'k8s')
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
+60
View File
@@ -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"},
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: {
nodegroups: {
list: "clouds.services.k8s.nodegroups.list",
@@ -154,6 +164,7 @@ export const apiEndpointKeys = {
},
},
flatItems: {
get: "clouds.services.flatItems.get",
list: "clouds.services.flatItems.list",
query: "clouds.services.flatItems.query",
create: "clouds.services.flatItems.create",
@@ -829,6 +840,48 @@ export const endpointRegistry: EndpointRegistry = new Map([
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,
{
@@ -998,6 +1051,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "GET",
},
],
[
apiEndpointKeys.clouds.flatItems.get,
{
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
method: "GET",
},
],
[
apiEndpointKeys.clouds.flatItems.list,
{
@@ -27,6 +27,11 @@ import type {
DatabaseSnapshot,
KubernetesFargateProfile,
KubernetesNodegroup,
CreateUserPoolGroupInput,
UserPoolGroup,
UserPoolSignInTestInput,
UserPoolSignInTestResult,
UserPoolUserAction,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
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(
cloud: CloudProvider,
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 {render, screen} from '@testing-library/react'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CognitoUsersPanel} from './CognitoUsersPanel'
@@ -9,7 +9,14 @@ const api = vi.hoisted(() => ({
deleteFlatItem: vi.fn(),
getCloudResource: vi.fn(),
listFlatItems: vi.fn(),
listUserPoolGroups: 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)
@@ -39,6 +46,11 @@ beforeEach(() => {
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
api.putFlatItem.mockResolvedValue(alice)
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 () => {
@@ -58,9 +70,10 @@ test('creates an email user who can sign in immediately', async () => {
renderPanel()
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
await user.type(screen.getByLabelText('Email'), 'bob@example.com')
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
await user.click(screen.getByRole('button', {name: 'Create user'}))
const form = screen.getByRole('form', {name: 'Create user'})
await user.type(within(form).getByLabelText('Email'), 'bob@example.com')
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})
})
@@ -70,10 +83,12 @@ test('creates a username user with a temporary password when asked', async () =>
api.getCloudResource.mockResolvedValue(pool('username'))
renderPanel()
await user.type(await screen.findByLabelText('Username'), 'bob')
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
await user.click(screen.getByLabelText(/User can sign in now/))
await user.click(screen.getByRole('button', {name: 'Create user'}))
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
const form = screen.getByRole('form', {name: 'Create user'})
await user.type(within(form).getByLabelText('Username'), 'bob')
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})
})
@@ -87,3 +102,52 @@ test('deletes a user by its stored username', async () => {
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 {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
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 {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
@@ -20,7 +23,9 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
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({
@@ -33,13 +38,25 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
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 ?? '', {
@@ -50,12 +67,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
onSuccess: () => {
setLogin('')
setPassword('')
return queryClient.invalidateQueries({queryKey: usersKey})
return refreshUsers()
},
})
const deleteMutation = useMutation({
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) {
@@ -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>
))}</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><p className="eyebrow">Users</p><span className="muted">{users.length} 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>
<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" 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>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>
@@ -97,9 +121,10 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
<td><code>{stringValue(user.body.status) || '—'}</code></td>
<td>{user.body.enabled === false ? 'No' : 'Yes'}</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>
})}</tbody></table></div>}
{openUser && <CognitoUserDetail key={openUser} poolId={resource.id} username={openUser} groups={groupsQuery.data ?? []} usersKey={usersKey} runtimeReachable={runtimeReachable} onClose={() => setOpenUser(null)}/>}
</section>
}
@@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
import { NetworkingPanel } from "@/components/NetworkingPanel";
import { describeDeleteImpact } from "@/lib/deleteImpact";
import { getPath } from "@/lib/resourcePath";
import {
capabilityEnabled,
@@ -574,12 +575,14 @@ export function DynamicResourceView({
const resources = resourcesQuery.data ?? [];
const canCreate = schema.actions.includes("create");
const canDelete = schema.actions.includes("delete");
const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems);
const isAllSelected =
resources.length > 0 &&
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 =
selected?.cloud === cloud && selected.service === service
? selected
? resources.find((resource) => resource.id === selected.id) ?? selected
: undefined;
const runtimeReachable = cloudStatus?.runtime === "reachable";
const resourceCapabilityInputs =
@@ -744,6 +747,9 @@ export function DynamicResourceView({
{canDelete && (
deleteConfirm ? (
<>
{deleteImpactText && (
<span className="delete-impact" role="status">{deleteImpactText}</span>
)}
<button
className="button danger"
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 {Link} from 'react-router-dom';
import {Pencil, Trash2} from 'lucide-react';
import {describeDeleteImpact} from '@/lib/deleteImpact';
import type {CloudResource} from '@/types/resource';
import type {ServiceSchema} from '@/types/schema';
import {getPath} from '@/lib/resourcePath';
@@ -140,6 +141,7 @@ export function ResourceTable({
className="table-confirm-actions"
onClick={(e) => e.stopPropagation()}
>
<DeleteImpact schema={schema} resource={resource} />
<button
className="button danger compact"
type="button"
@@ -207,3 +209,8 @@ function CopyableColumnValue({resource, column}: {resource: CloudResource; colum
</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;
}
+145
View File
@@ -779,6 +779,47 @@ pre,
position: relative;
}
@media (max-width: 640px) {
.topbar {
gap: 8px;
padding: 0 10px;
}
.topbar .search {
flex: 1 1 auto;
min-width: 0;
}
.topbar .search input {
padding-right: 8px;
}
.topbar .kbd,
.topbar .account-meta,
.topbar .connection-target {
display: none;
}
.topbar .connection-state {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.topbar .connection {
flex: 0 0 auto;
padding: 8px;
}
.topbar .account-trigger {
gap: 4px;
padding: 0 6px;
}
}
.topbar-status:empty + .account-switcher {
margin-left: auto;
}
@@ -4266,6 +4307,110 @@ pre,
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 {
display: grid;
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()
})
+25
View File
@@ -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}.`
}
+15 -3
View File
@@ -2,9 +2,17 @@ import {useEffect, useState} from 'react'
export type Theme = 'dark' | 'light' | 'cyber'
/** Shared with the login page (public/login.js) and index.html. */
const THEME_KEY = 'floci-theme'
function getStored(): Theme {
const v = localStorage.getItem('floci-theme')
return v === 'light' || v === 'cyber' ? v : 'dark'
try {
const v = localStorage.getItem(THEME_KEY)
return v === 'light' || v === 'cyber' ? v : 'dark'
} catch {
// localStorage unavailable (private mode) — fall back to the default theme.
return 'dark'
}
}
export function useTheme() {
@@ -12,7 +20,11 @@ export function useTheme() {
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('floci-theme', theme)
try {
localStorage.setItem(THEME_KEY, theme)
} catch {
// Persisting the choice is optional when storage is unavailable.
}
}, [theme])
return {theme, setTheme}
@@ -49,7 +49,9 @@ test('summarises the runtime and lists failing console services first', async ()
expect(screen.getByText('1 not running')).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
const consoleServices = screen.getByRole('region', {name: 'Console services'})
const rows = within(consoleServices).getAllByRole('row')
expect(rows).toHaveLength(2)
expect(rows[1]).toHaveTextContent('messaging')
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
@@ -59,6 +61,25 @@ test('summarises the runtime and lists failing console services first', async ()
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
})
test('keeps responding console services behind a toggle', async () => {
const user = userEvent.setup()
api.getCloudDiagnostics.mockResolvedValue({
...healthy,
consoleServices: healthy.consoleServices.map((service) => ({...service, runtime: 'reachable', error: null, errorCode: null})),
} satisfies RuntimeDiagnostics)
renderPage()
const consoleServices = await screen.findByRole('region', {name: 'Console services'})
expect(within(consoleServices).getByText('All 2 responding')).toBeInTheDocument()
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
await user.click(within(consoleServices).getByRole('button', {name: 'Show 2 responding'}))
expect(within(consoleServices).getAllByRole('row')).toHaveLength(3)
await user.click(within(consoleServices).getByRole('button', {name: 'Hide responding'}))
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
})
test('filters emulated services by console coverage and state', async () => {
const user = userEvent.setup()
api.getCloudDiagnostics.mockResolvedValue(healthy)
@@ -88,17 +88,25 @@ function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
}
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
const [showResponding, setShowResponding] = useState(false)
const failing = services.filter((service) => service.runtime !== 'reachable')
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
const responding = services.filter((service) => service.runtime === 'reachable')
const ordered = showResponding ? [...failing, ...responding] : failing
return (
<section className="table-panel" aria-labelledby="diagnostics-console-services">
<header className="diagnostics-section-header">
<h2 id="diagnostics-console-services">Console services</h2>
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
<div className="diagnostics-filters">
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `All ${services.length} responding`}</span>
{responding.length > 0 && (
<button className="button compact" type="button" aria-expanded={showResponding} onClick={() => setShowResponding((value) => !value)}>
{showResponding ? 'Hide responding' : `Show ${responding.length} responding`}
</button>
)}
</div>
</header>
{services.length === 0
? <p className="muted padded">No console services are registered for this cloud.</p>
: <table className="table">
{services.length === 0 && <p className="muted padded">No console services are registered for this cloud.</p>}
{ordered.length > 0 && <table className="table">
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
<tbody>{ordered.map((service) => (
<tr key={service.service}>
+32
View File
@@ -274,3 +274,35 @@ export interface AppConfigDeployment {
completedAt: 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}
+4
View File
@@ -27,6 +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 CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -86,8 +87,11 @@ export interface ServiceSchema {
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
}
filters: FieldSchema[]
columns: TableColumnSchema[]
updateFields?: FieldSchema[]
/** Counts read from the resource that the delete confirmation names. */
deleteImpact?: Array<{path: string; singular: string; plural: string}>
}