Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d457c08b12 | |||
| 4bc8da6ee1 | |||
| bafe799d00 | |||
| 43f4cae9b5 | |||
| b4c2cadf5c | |||
| 8803498498 | |||
| f6b9a62811 | |||
| f1574b89df | |||
| fe70e88bcc | |||
| 4156afccfe | |||
| 8b166d86d4 | |||
| f956d977ca | |||
| 9f38313e59 | |||
| ece36e4a34 | |||
| 953ba951db | |||
| dd16bcf4cd |
@@ -62,14 +62,21 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
||||||
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
||||||
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
||||||
| Compute | CodeBuild | Yes (list, create, delete, inspect) | No | No |
|
| Compute | ECS | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Compute | Fargate | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Compute | Lightsail | Yes (list, create, inspect, update, delete) | No | No |
|
||||||
|
| Developer Tools | CodeBuild | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Developer Tools | CodePipeline | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
||||||
|
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
||||||
|
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Networking | Networking | Yes (list) | No | No |
|
| Networking | Networking | Yes (list) | No | No |
|
||||||
| Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No |
|
| Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No |
|
| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||||
@@ -77,11 +84,14 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
||||||
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
||||||
| Integration | CodePipeline | Yes (list, create, delete, inspect) | No | No |
|
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No |
|
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
|
||||||
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No |
|
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
||||||
|
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
||||||
|
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Security | Cognito / User Directories | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@aws-sdk/client-codebuild": "^3.1076.0",
|
"@aws-sdk/client-codebuild": "^3.1076.0",
|
||||||
"@aws-sdk/client-codedeploy": "^3.1144.0",
|
"@aws-sdk/client-codedeploy": "^3.1144.0",
|
||||||
"@aws-sdk/client-codepipeline": "^3.1076.0",
|
"@aws-sdk/client-codepipeline": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-cognito-identity-provider": "^3.1146.0",
|
||||||
"@aws-sdk/client-dynamodb": "^3.1088.0",
|
"@aws-sdk/client-dynamodb": "^3.1088.0",
|
||||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||||
|
|||||||
@@ -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 root = document.documentElement;
|
||||||
const buttons = document.querySelectorAll('.tg button');
|
const buttons = document.querySelectorAll('.tg button');
|
||||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
@@ -6,7 +8,7 @@
|
|||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
root.setAttribute('data-theme', theme);
|
root.setAttribute('data-theme', theme);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('thecloud-theme', theme);
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
} catch (ignored) {
|
} catch (ignored) {
|
||||||
// Theme persistence is optional when browser storage is unavailable.
|
// Theme persistence is optional when browser storage is unavailable.
|
||||||
}
|
}
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
|
|
||||||
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
try {
|
try {
|
||||||
const savedTheme = localStorage.getItem('thecloud-theme');
|
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
|
||||||
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||||
initialTheme = savedTheme;
|
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 () => {
|
test('filters messages by subject, sender, and recipients', async () => {
|
||||||
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
@@ -125,10 +125,18 @@ function messageToResource(message: FlociSesMessage): CloudResource {
|
|||||||
htmlBody: message.Body?.html_part ?? null,
|
htmlBody: message.Body?.html_part ?? null,
|
||||||
rawData: message.RawData ?? null,
|
rawData: message.RawData ?? null,
|
||||||
messageType: raw ? 'raw' : 'simple',
|
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[] {
|
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||||
const normalized = search?.trim().toLowerCase()
|
const normalized = search?.trim().toLowerCase()
|
||||||
if (!normalized) return resources
|
if (!normalized) return resources
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
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,
|
||||||
|
AdminSetUserPasswordCommand,
|
||||||
|
CreateUserPoolClientCommand,
|
||||||
|
CreateUserPoolCommand,
|
||||||
|
DeleteUserPoolCommand,
|
||||||
|
DescribeUserPoolCommand,
|
||||||
|
ListUserPoolClientsCommand,
|
||||||
|
ListUserPoolsCommand,
|
||||||
|
ListUsersCommand,
|
||||||
|
type CognitoIdentityProviderClient,
|
||||||
|
} from '@aws-sdk/client-cognito-identity-provider'
|
||||||
|
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||||
|
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stubCognito(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
} as unknown as CognitoIdentityProviderClient
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
function awsError(name: string): Error {
|
||||||
|
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}})
|
||||||
|
}
|
||||||
|
|
||||||
|
const POOL_ID = 'us-east-1_8e65d229a'
|
||||||
|
const CREATED = new Date('2026-10-23T16:01:22.000Z')
|
||||||
|
|
||||||
|
const pool = {
|
||||||
|
Id: POOL_ID,
|
||||||
|
Name: 'orders-users',
|
||||||
|
Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`,
|
||||||
|
Status: 'Enabled',
|
||||||
|
CreationDate: CREATED,
|
||||||
|
UsernameAttributes: ['email'],
|
||||||
|
EstimatedNumberOfUsers: 1,
|
||||||
|
MfaConfiguration: 'OFF',
|
||||||
|
DeletionProtection: 'INACTIVE',
|
||||||
|
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
const alice = {
|
||||||
|
Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436',
|
||||||
|
Attributes: [
|
||||||
|
{Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'},
|
||||||
|
{Name: 'email', Value: 'alice@example.com'},
|
||||||
|
{Name: 'email_verified', Value: 'true'},
|
||||||
|
],
|
||||||
|
UserCreateDate: CREATED,
|
||||||
|
UserLastModifiedDate: CREATED,
|
||||||
|
Enabled: true,
|
||||||
|
UserStatus: 'CONFIRMED',
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeStub() {
|
||||||
|
return stubCognito((command) => {
|
||||||
|
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
||||||
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
||||||
|
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]}
|
||||||
|
if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}}
|
||||||
|
if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsUserPoolsAdapter', () => {
|
||||||
|
test('lists every page of user pools and filters by name or id', async () => {
|
||||||
|
const {client, sent} = stubCognito((command) => {
|
||||||
|
if (!(command instanceof ListUserPoolsCommand)) return {}
|
||||||
|
return command.input.NextToken
|
||||||
|
? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]}
|
||||||
|
: {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'}
|
||||||
|
})
|
||||||
|
const adapter = new AwsUserPoolsAdapter(client)
|
||||||
|
|
||||||
|
expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second'])
|
||||||
|
expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2)
|
||||||
|
expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing'])
|
||||||
|
expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists pools with the details the inspector shows', async () => {
|
||||||
|
const {client} = stubCognito((command) => {
|
||||||
|
if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]}
|
||||||
|
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
||||||
|
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [listed] = await new AwsUserPoolsAdapter(client).list()
|
||||||
|
expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes a pool with its sign-in method, password policy and app clients', async () => {
|
||||||
|
const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID)
|
||||||
|
|
||||||
|
expect(resource?.metadata).toEqual({
|
||||||
|
arn: pool.Arn,
|
||||||
|
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}}}}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a pool the runtime does not know', async () => {
|
||||||
|
const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') })
|
||||||
|
|
||||||
|
expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an email pool with a public app client by default', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})
|
||||||
|
|
||||||
|
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
||||||
|
expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']})
|
||||||
|
const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand
|
||||||
|
expect(createClient.input).toEqual({
|
||||||
|
UserPoolId: POOL_ID,
|
||||||
|
ClientName: 'orders-users-client',
|
||||||
|
GenerateSecret: false,
|
||||||
|
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a username pool with a relaxed password policy and no app client', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}})
|
||||||
|
|
||||||
|
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
||||||
|
expect(createPool.input).toEqual({
|
||||||
|
PoolName: 'testers',
|
||||||
|
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}},
|
||||||
|
})
|
||||||
|
expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removes the new pool when its app client cannot be created', async () => {
|
||||||
|
const {client, sent} = stubCognito((command) => {
|
||||||
|
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
||||||
|
if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException')
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException')
|
||||||
|
expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects invalid create input before calling the runtime', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
const adapter = new AwsUserPoolsAdapter(client)
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
expect(sent).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a pool by id', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
await new AwsUserPoolsAdapter(client).delete(POOL_ID)
|
||||||
|
|
||||||
|
expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand)
|
||||||
|
expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AwsUserPoolsAdapter users', () => {
|
||||||
|
test('lists users as items keyed by username with their email and status', async () => {
|
||||||
|
const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'}))
|
||||||
|
const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'})
|
||||||
|
|
||||||
|
expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'})
|
||||||
|
expect(page.nextCursor).toBe('next-page')
|
||||||
|
expect(page.items).toEqual([{
|
||||||
|
id: alice.Username,
|
||||||
|
collectionId: null,
|
||||||
|
timestamp: CREATED.toISOString(),
|
||||||
|
body: {
|
||||||
|
username: alice.Username,
|
||||||
|
email: 'alice@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
enabled: true,
|
||||||
|
lastModifiedAt: CREATED.toISOString(),
|
||||||
|
attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'},
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a user whose password is permanent so it can sign in at once', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '})
|
||||||
|
|
||||||
|
expect((sent[0] as AdminCreateUserCommand).input).toEqual({
|
||||||
|
UserPoolId: POOL_ID,
|
||||||
|
Username: 'alice@example.com',
|
||||||
|
UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}],
|
||||||
|
TemporaryPassword: ' Passw0rd! ',
|
||||||
|
MessageAction: 'SUPPRESS',
|
||||||
|
})
|
||||||
|
expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true})
|
||||||
|
expect(user.body.status).toBe('CONFIRMED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves the password temporary when asked', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||||
|
|
||||||
|
expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob')
|
||||||
|
expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([])
|
||||||
|
expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a user without a name, password or valid email', async () => {
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
const users = new AwsUserPoolsAdapter(client).items
|
||||||
|
|
||||||
|
await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address')
|
||||||
|
await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required')
|
||||||
|
await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address')
|
||||||
|
await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false')
|
||||||
|
expect(sent).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a missing user and deletes by username', async () => {
|
||||||
|
const missing = stubCognito(() => { throw awsError('UserNotFoundException') })
|
||||||
|
expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull()
|
||||||
|
|
||||||
|
const {client, sent} = runtimeStub()
|
||||||
|
await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username)
|
||||||
|
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'})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
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,
|
||||||
|
type UserPoolType,
|
||||||
|
type UserType,
|
||||||
|
} from '@aws-sdk/client-cognito-identity-provider'
|
||||||
|
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||||
|
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,
|
||||||
|
awsUserPoolsSchema,
|
||||||
|
} from '../cloud-spi/userPoolsSchema'
|
||||||
|
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@]+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Cognito user pools — verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - A missing pool is `ResourceNotFoundException` and a missing user is
|
||||||
|
* `UserNotFoundException`, both with HTTP **400**; `get` matches on the name.
|
||||||
|
* - With email sign-in the stored `Username` is the generated `sub`, not the
|
||||||
|
* address, so users are keyed by `Username` and the email is read from the
|
||||||
|
* attributes. Admin calls accept either form.
|
||||||
|
* - An omitted password policy defaults to requiring upper, lower, number and
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'userpools' as const
|
||||||
|
readonly items: ItemStoreAdapter
|
||||||
|
|
||||||
|
constructor(private readonly cognito: CognitoIdentityProviderClient) {
|
||||||
|
this.items = new UserStore(cognito)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsUserPoolsSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const pools: UserPoolDescriptionType[] = []
|
||||||
|
let nextToken: string | undefined
|
||||||
|
|
||||||
|
do {
|
||||||
|
const res = await this.cognito.send(new ListUserPoolsCommand({MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||||
|
pools.push(...(res.UserPools ?? []))
|
||||||
|
nextToken = res.NextToken || undefined
|
||||||
|
} while (nextToken)
|
||||||
|
|
||||||
|
// ListUserPools omits the sign-in method, password policy and app clients the
|
||||||
|
// inspector shows, so each match is described. Pools are few on a local runtime.
|
||||||
|
const matches = filterBySearch(pools.flatMap(toSummaryResource), query.search)
|
||||||
|
return Promise.all(matches.map(async (summary) => (await this.get(summary.id)) ?? summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
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, userCount) : null
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissing(error)) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const name = requiredString(input.values.name, 'name')
|
||||||
|
if (!new RegExp(USER_POOL_NAME_PATTERN).test(name)) {
|
||||||
|
throw new ValidationError('name must be up to 128 letters, digits, spaces or + = , . @ _ -')
|
||||||
|
}
|
||||||
|
const signInWith = optionalOneOf(input.values.signInWith, USER_POOL_SIGN_IN, 'signInWith') ?? 'email'
|
||||||
|
const passwordPolicy = optionalOneOf(input.values.passwordPolicy, USER_POOL_PASSWORD_POLICIES, 'passwordPolicy') ?? 'default'
|
||||||
|
const createAppClient = optionalOneOf(input.values.createAppClient, ['yes', 'no'] as const, 'createAppClient') ?? 'yes'
|
||||||
|
|
||||||
|
const created = await this.cognito.send(new CreateUserPoolCommand({
|
||||||
|
PoolName: name,
|
||||||
|
...(signInWith === 'email' ? {UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']} : {}),
|
||||||
|
...(passwordPolicy === 'relaxed'
|
||||||
|
? {Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}
|
||||||
|
: {}),
|
||||||
|
}))
|
||||||
|
const id = created.UserPool?.Id
|
||||||
|
if (!id) throw new RuntimeError(`Cognito did not return the created user pool ${name}`)
|
||||||
|
|
||||||
|
if (createAppClient === 'yes') {
|
||||||
|
try {
|
||||||
|
await this.cognito.send(new CreateUserPoolClientCommand({
|
||||||
|
UserPoolId: id,
|
||||||
|
ClientName: `${name}-client`,
|
||||||
|
GenerateSecret: false,
|
||||||
|
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
// Without its client the pool is not what was asked for; do not leave it half-made.
|
||||||
|
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})).catch(() => undefined)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = await this.get(id)
|
||||||
|
if (!pool) throw new RuntimeError(`Cognito did not return the created user pool ${id}`)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
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
|
||||||
|
|
||||||
|
do {
|
||||||
|
const res = await this.cognito.send(new ListUserPoolClientsCommand({UserPoolId: poolId, MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||||
|
clients.push(...(res.UserPoolClients ?? []))
|
||||||
|
nextToken = res.NextToken || undefined
|
||||||
|
} while (nextToken)
|
||||||
|
|
||||||
|
return clients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserStore implements ItemStoreAdapter {
|
||||||
|
constructor(private readonly cognito: CognitoIdentityProviderClient) {}
|
||||||
|
|
||||||
|
async listItems(poolId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||||
|
const res = await this.cognito.send(new ListUsersCommand({
|
||||||
|
UserPoolId: poolId,
|
||||||
|
Limit: Math.min(page.limit ?? COGNITO_PAGE_LIMIT, COGNITO_PAGE_LIMIT),
|
||||||
|
...(page.cursor ? {PaginationToken: page.cursor} : {}),
|
||||||
|
}))
|
||||||
|
return {items: (res.Users ?? []).flatMap((user) => toUserItem(user.Username, user.Attributes, user)), nextCursor: res.PaginationToken || null}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
|
||||||
|
try {
|
||||||
|
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a user that can sign in straight away: the password is set as
|
||||||
|
* permanent unless `permanent` is false, in which case Cognito will demand a
|
||||||
|
* new one at first sign-in (`FORCE_CHANGE_PASSWORD`).
|
||||||
|
*/
|
||||||
|
async putItem(poolId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||||
|
const email = optionalString(body.email, 'email')
|
||||||
|
if (email && !EMAIL_PATTERN.test(email)) throw new ValidationError('email must be a valid email address')
|
||||||
|
const username = optionalString(body.username, 'username') ?? email
|
||||||
|
if (!username) throw new ValidationError('Provide a username or an email address')
|
||||||
|
// Not trimmed: leading or trailing spaces are part of a password.
|
||||||
|
const password = body.password
|
||||||
|
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
|
||||||
|
if (body.permanent !== undefined && typeof body.permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
|
||||||
|
|
||||||
|
const created = await this.cognito.send(new AdminCreateUserCommand({
|
||||||
|
UserPoolId: poolId,
|
||||||
|
Username: username,
|
||||||
|
UserAttributes: email ? [{Name: 'email', Value: email}, {Name: 'email_verified', Value: 'true'}] : [],
|
||||||
|
TemporaryPassword: password,
|
||||||
|
MessageAction: 'SUPPRESS',
|
||||||
|
}))
|
||||||
|
const storedUsername = created.User?.Username
|
||||||
|
if (!storedUsername) throw new RuntimeError(`Cognito did not return the user created in ${poolId}`)
|
||||||
|
|
||||||
|
if (body.permanent !== false) {
|
||||||
|
await this.cognito.send(new AdminSetUserPasswordCommand({UserPoolId: poolId, Username: storedUsername, Password: password, Permanent: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.getItem(poolId, storedUsername)
|
||||||
|
if (!user) throw new RuntimeError(`Cognito did not return the user ${storedUsername} in ${poolId}`)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteItem(poolId: string, username: string): Promise<void> {
|
||||||
|
await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 [{
|
||||||
|
id: pool.Id,
|
||||||
|
name: pool.Name ?? pool.Id,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'userpools',
|
||||||
|
type: 'user-pool',
|
||||||
|
region: regionFromPoolId(pool.Id),
|
||||||
|
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
|
||||||
|
status: pool.Status ?? null,
|
||||||
|
metadata: {},
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], userCount: number): CloudResource {
|
||||||
|
const id = pool.Id ?? ''
|
||||||
|
const policy = pool.Policies?.PasswordPolicy
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: pool.Name ?? id,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'userpools',
|
||||||
|
type: 'user-pool',
|
||||||
|
region: regionFromPoolId(id),
|
||||||
|
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
|
||||||
|
status: pool.Status ?? null,
|
||||||
|
metadata: {
|
||||||
|
arn: pool.Arn,
|
||||||
|
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 ?? ''})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUserItem(
|
||||||
|
username: string | undefined,
|
||||||
|
attributes: AttributeType[] | undefined,
|
||||||
|
user: Pick<UserType, 'Enabled' | 'UserStatus' | 'UserCreateDate' | 'UserLastModifiedDate'>,
|
||||||
|
): ChildItem[] {
|
||||||
|
if (!username) return []
|
||||||
|
const attributeMap = Object.fromEntries((attributes ?? []).flatMap((attribute) => attribute.Name ? [[attribute.Name, attribute.Value ?? '']] : []))
|
||||||
|
return [{
|
||||||
|
id: username,
|
||||||
|
collectionId: null,
|
||||||
|
timestamp: user.UserCreateDate ? user.UserCreateDate.toISOString() : null,
|
||||||
|
body: {
|
||||||
|
username,
|
||||||
|
email: attributeMap.email ?? null,
|
||||||
|
emailVerified: attributeMap.email_verified === 'true',
|
||||||
|
status: user.UserStatus ?? null,
|
||||||
|
enabled: user.Enabled ?? true,
|
||||||
|
lastModifiedAt: user.UserLastModifiedDate ? user.UserLastModifiedDate.toISOString() : null,
|
||||||
|
attributes: attributeMap,
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One readable line for the inspector, e.g. "8+ characters with uppercase, number". */
|
||||||
|
function describePasswordPolicy(policy: PasswordPolicyType): string {
|
||||||
|
const required = [
|
||||||
|
policy.RequireUppercase && 'uppercase',
|
||||||
|
policy.RequireLowercase && 'lowercase',
|
||||||
|
policy.RequireNumbers && 'number',
|
||||||
|
policy.RequireSymbols && 'symbol',
|
||||||
|
].filter((rule): rule is string => Boolean(rule))
|
||||||
|
const length = `${policy.MinimumLength ?? 8}+ characters`
|
||||||
|
return required.length ? `${length} with ${required.join(', ')}` : length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pool ids are `<region>_<suffix>`. */
|
||||||
|
function regionFromPoolId(id: string): string | null {
|
||||||
|
const separator = id.indexOf('_')
|
||||||
|
return separator > 0 ? id.slice(0, separator) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||||
|
const normalized = search?.trim().toLowerCase()
|
||||||
|
if (!normalized) return resources
|
||||||
|
|
||||||
|
return resources.filter(
|
||||||
|
(resource) =>
|
||||||
|
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissing(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null) return false
|
||||||
|
const name = (error as {name?: string}).name
|
||||||
|
return name === 'ResourceNotFoundException' || name === 'UserNotFoundException'
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: unknown, field: string): string {
|
||||||
|
const trimmed = optionalString(value, field)
|
||||||
|
if (!trimmed) throw new ValidationError(`${field} is required`)
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown, field: string): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined
|
||||||
|
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
|
||||||
|
return value.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalOneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T | undefined {
|
||||||
|
const text = optionalString(value, field)
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
if (!(allowed as readonly string[]).includes(text)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||||
|
return text as T
|
||||||
|
}
|
||||||
@@ -127,3 +127,23 @@ describe('logs errors carry HTTP 400 and must not fall through to it', () => {
|
|||||||
expect(mapped?.status).toBe(409)
|
expect(mapped?.status).toBe(409)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cognito reports every failure with HTTP 400, so these names must be mapped
|
||||||
|
* explicitly or a missing user would read as a validation error.
|
||||||
|
*/
|
||||||
|
describe('cognito errors carry HTTP 400 and keep their meaning', () => {
|
||||||
|
const cases: Array<[string, CloudErrorStatus]> = [
|
||||||
|
['UserNotFoundException', 404],
|
||||||
|
['UsernameExistsException', 409],
|
||||||
|
['AliasExistsException', 409],
|
||||||
|
['GroupExistsException', 409],
|
||||||
|
['InvalidPasswordException', 400],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [name, status] of cases) {
|
||||||
|
test(`${name} maps to ${status}`, () => {
|
||||||
|
expect(mapAwsSdkError(sdkError(name, {$metadata: {httpStatusCode: 400}}))?.status).toBe(status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ const CONFLICT_NAMES = new Set([
|
|||||||
'ConcurrentModificationException',
|
'ConcurrentModificationException',
|
||||||
'IncorrectState',
|
'IncorrectState',
|
||||||
'DeleteConflictException',
|
'DeleteConflictException',
|
||||||
|
'UsernameExistsException',
|
||||||
|
'AliasExistsException',
|
||||||
|
'GroupExistsException',
|
||||||
])
|
])
|
||||||
|
|
||||||
const ACCESS_DENIED_NAMES = new Set([
|
const ACCESS_DENIED_NAMES = new Set([
|
||||||
@@ -90,6 +93,7 @@ const VALIDATION_NAMES = new Set([
|
|||||||
'InvalidBucketName',
|
'InvalidBucketName',
|
||||||
'InvalidArgumentException',
|
'InvalidArgumentException',
|
||||||
'InvalidParameterException',
|
'InvalidParameterException',
|
||||||
|
'InvalidPasswordException',
|
||||||
])
|
])
|
||||||
|
|
||||||
const NOT_FOUND_NAMES = new Set([
|
const NOT_FOUND_NAMES = new Set([
|
||||||
@@ -119,6 +123,7 @@ const NOT_FOUND_NAMES = new Set([
|
|||||||
'InvalidSubnetID.NotFound',
|
'InvalidSubnetID.NotFound',
|
||||||
'InvalidGroupId.NotFound',
|
'InvalidGroupId.NotFound',
|
||||||
'InvalidAMIID.NotFound',
|
'InvalidAMIID.NotFound',
|
||||||
|
'UserNotFoundException',
|
||||||
])
|
])
|
||||||
|
|
||||||
const NOT_IMPLEMENTED_NAMES = new Set([
|
const NOT_IMPLEMENTED_NAMES = new Set([
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { SESv2Client } from "@aws-sdk/client-sesv2";
|
|||||||
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||||
|
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||||
|
|
||||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||||
const region = process.env.AWS_REGION || "us-east-1";
|
const region = process.env.AWS_REGION || "us-east-1";
|
||||||
@@ -91,6 +92,7 @@ export type AwsClients = {
|
|||||||
cloudwatch: CloudWatchClient;
|
cloudwatch: CloudWatchClient;
|
||||||
elasticache: ElastiCacheClient;
|
elasticache: ElastiCacheClient;
|
||||||
lightsail: LightsailClient;
|
lightsail: LightsailClient;
|
||||||
|
cognito: CognitoIdentityProviderClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AwsClientName = keyof AwsClients;
|
export type AwsClientName = keyof AwsClients;
|
||||||
@@ -136,6 +138,7 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
cloudwatch: new CloudWatchClient(base),
|
cloudwatch: new CloudWatchClient(base),
|
||||||
elasticache: new ElastiCacheClient(base),
|
elasticache: new ElastiCacheClient(base),
|
||||||
lightsail: new LightsailClient(base),
|
lightsail: new LightsailClient(base),
|
||||||
|
cognito: new CognitoIdentityProviderClient(base),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const emailColumns: TableColumnSchema[] = [
|
|||||||
{name: 'name', label: 'Subject'},
|
{name: 'name', label: 'Subject'},
|
||||||
{name: 'source', label: 'From', path: 'metadata.source', format: 'code'},
|
{name: 'source', label: 'From', path: 'metadata.source', format: 'code'},
|
||||||
{name: 'to', label: 'To', path: 'metadata.toAddresses', format: 'list'},
|
{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: 'messageType', label: 'Type', path: 'metadata.messageType', format: 'badge'},
|
||||||
{name: 'createdAt', label: 'Captured At', format: 'datetime'},
|
{name: 'createdAt', label: 'Captured At', format: 'datetime'},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ export const SERVICE_CATALOG = {
|
|||||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||||
routeByCloud: {aws: '/secretsmanager'},
|
routeByCloud: {aws: '/secretsmanager'},
|
||||||
},
|
},
|
||||||
|
userpools: {
|
||||||
|
displayName: 'User Directories',
|
||||||
|
displayNameByCloud: {aws: 'Cognito'},
|
||||||
|
iconKey: 'userpools',
|
||||||
|
group: 'Security',
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
iac: {
|
iac: {
|
||||||
displayName: 'Infrastructure as Code',
|
displayName: 'Infrastructure as Code',
|
||||||
displayNameByCloud: {aws: 'CloudFormation'},
|
displayNameByCloud: {aws: 'CloudFormation'},
|
||||||
|
|||||||
@@ -77,6 +77,48 @@ export interface CloudServiceStatus {
|
|||||||
errorCode: string | null
|
errorCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One service as the runtime itself reports it, which can be far more than the console covers. */
|
||||||
|
export interface EmulatedServiceStatus {
|
||||||
|
/** The runtime's own service id, e.g. `states` or `elasticloadbalancing`. */
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
/** Console pages backed by this service; empty when the console has none yet. */
|
||||||
|
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A container the runtime started to back a resource, as reported by its diagnostics. */
|
||||||
|
export interface RuntimeContainer {
|
||||||
|
kind: 'ec2-instance'
|
||||||
|
resourceId: string
|
||||||
|
status: string | null
|
||||||
|
sshHost: string | null
|
||||||
|
sshPort: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDiagnostics {
|
||||||
|
cloud: CloudProvider
|
||||||
|
checkedAt: string
|
||||||
|
runtime: {
|
||||||
|
status: RuntimeReachability
|
||||||
|
endpoint: string | null
|
||||||
|
latencyMs: number | null
|
||||||
|
error: string | null
|
||||||
|
version: string | null
|
||||||
|
edition: string | null
|
||||||
|
}
|
||||||
|
/** Null when the runtime does not publish a per-service list. */
|
||||||
|
emulatedServices: EmulatedServiceStatus[] | null
|
||||||
|
consoleServices: CloudServiceStatus[]
|
||||||
|
/** Null when the runtime does not publish container diagnostics. */
|
||||||
|
containers: RuntimeContainer[] | null
|
||||||
|
console: {
|
||||||
|
startedAt: string
|
||||||
|
uptimeSeconds: number
|
||||||
|
authMode: 'local' | 'oidc'
|
||||||
|
activeSessions: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
@@ -129,6 +171,13 @@ export type KubernetesActionName =
|
|||||||
| 'listFargateProfiles'
|
| 'listFargateProfiles'
|
||||||
| 'createFargateProfile'
|
| 'createFargateProfile'
|
||||||
| 'deleteFargateProfile'
|
| 'deleteFargateProfile'
|
||||||
|
export type UserPoolActionName =
|
||||||
|
| 'updateUser'
|
||||||
|
| 'setUserGroups'
|
||||||
|
| 'listGroups'
|
||||||
|
| 'createGroup'
|
||||||
|
| 'deleteGroup'
|
||||||
|
| 'testSignIn'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
export interface CapabilitySchema<TAction extends string> {
|
export interface CapabilitySchema<TAction extends string> {
|
||||||
@@ -172,6 +221,7 @@ export interface ServiceSchema {
|
|||||||
objectActions?: CapabilitySchema<ObjectActionName>[]
|
objectActions?: CapabilitySchema<ObjectActionName>[]
|
||||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||||
|
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||||
// Child levels advertise separately from the resource level because a
|
// Child levels advertise separately from the resource level because a
|
||||||
// store can be readable at the leaf and writable at the collection —
|
// store can be readable at the leaf and writable at the collection —
|
||||||
// CloudWatch Logs creates streams but cannot delete an event.
|
// CloudWatch Logs creates streams but cannot delete an event.
|
||||||
@@ -181,6 +231,11 @@ export interface ServiceSchema {
|
|||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
columns: TableColumnSchema[]
|
columns: TableColumnSchema[]
|
||||||
updateFields?: FieldSchema[]
|
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 =
|
export type KnownResourceType =
|
||||||
@@ -188,7 +243,7 @@ export type KnownResourceType =
|
|||||||
| 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
|
| 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
|
||||||
| 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue'
|
| 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue'
|
||||||
| 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server'
|
| 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server'
|
||||||
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group'
|
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' | 'user-pool'
|
||||||
|
|
||||||
export interface CloudResource {
|
export interface CloudResource {
|
||||||
id: string
|
id: string
|
||||||
@@ -369,6 +424,40 @@ export interface SqsMoveTaskResult {
|
|||||||
taskHandle: string
|
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. */
|
/** Provider-neutral representation of a managed Kubernetes node pool. */
|
||||||
export interface KubernetesNodegroup {
|
export interface KubernetesNodegroup {
|
||||||
id: string
|
id: string
|
||||||
@@ -875,6 +964,12 @@ export interface CloudServiceAdapter {
|
|||||||
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
|
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
|
||||||
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
|
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
|
||||||
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
|
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. */
|
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||||
clearEmailInbox?(): Promise<void>
|
clearEmailInbox?(): Promise<void>
|
||||||
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** Cognito's own limit and character set for user pool names. */
|
||||||
|
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'},
|
||||||
|
{name: 'id', label: 'Pool ID', format: 'code'},
|
||||||
|
{name: 'status', label: 'Status', format: 'badge'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const userPoolFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsUserPoolsSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'userpools',
|
||||||
|
displayName: 'Amazon Cognito user pools',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Pool name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: USER_POOL_NAME_PATTERN, message: 'Use up to 128 letters, digits, spaces or + = , . @ _ -'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'signInWith',
|
||||||
|
label: 'Sign in with',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'email',
|
||||||
|
description: 'Email makes the address the sign-in name; username keeps a separate user name.',
|
||||||
|
options: [{label: 'Email address', value: 'email'}, {label: 'Username', value: 'username'}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'passwordPolicy',
|
||||||
|
label: 'Password policy',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'default',
|
||||||
|
description: 'Relaxed only requires 8 characters, which keeps local test users quick to create.',
|
||||||
|
options: [
|
||||||
|
{label: 'AWS default (upper, lower, number, symbol)', value: 'default'},
|
||||||
|
{label: 'Relaxed (8+ characters)', value: 'relaxed'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createAppClient',
|
||||||
|
label: 'App client',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'yes',
|
||||||
|
description: 'A public client (no secret) with username/password and refresh-token sign-in, named <pool>-client.',
|
||||||
|
options: [{label: 'Create an app client', value: 'yes'}, {label: 'No app client', value: 'no'}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List user pools', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
itemActions: [
|
||||||
|
{name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'get', label: 'Get user', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{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'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsUserPoolsSchema() : null
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
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.
|
* 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',
|
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()
|
const registry = createCloudAdapterRegistry()
|
||||||
|
|
||||||
function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> {
|
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)}()`,
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
).toBe('function')
|
).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`, () => {
|
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?.objectActions ?? []),
|
||||||
...(adapter.schema().capabilities?.databaseActions ?? []),
|
...(adapter.schema().capabilities?.databaseActions ?? []),
|
||||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
|
|||||||
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||||
|
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||||
import {createEc2Service} from './services/ec2'
|
import {createEc2Service} from './services/ec2'
|
||||||
import {createEksService} from './services/eks'
|
import {createEksService} from './services/eks'
|
||||||
@@ -96,6 +97,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsKinesisAdapter(clients.kinesis),
|
new AwsKinesisAdapter(clients.kinesis),
|
||||||
new AwsElastiCacheAdapter(clients.elasticache),
|
new AwsElastiCacheAdapter(clients.elasticache),
|
||||||
new AwsLightsailAdapter(clients.lightsail),
|
new AwsLightsailAdapter(clients.lightsail),
|
||||||
|
new AwsUserPoolsAdapter(clients.cognito),
|
||||||
new AzureStorageAdapter(),
|
new AzureStorageAdapter(),
|
||||||
new AzureServiceBusAdapter(),
|
new AzureServiceBusAdapter(),
|
||||||
new AzureDatabaseAdapter(),
|
new AzureDatabaseAdapter(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {awsEksSchema} from '../cloud-spi/eksSchema'
|
|||||||
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
||||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||||
|
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
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 () => {
|
test('clears the SES mailbox through the email adapter', async () => {
|
||||||
let cleared = false
|
let cleared = false
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
|
|||||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||||
import {serviceForAccount} from '../cloudProxy'
|
import {serviceForAccount} from '../cloudProxy'
|
||||||
import {CloudProxyService} from '../service/CloudProxyService'
|
import {CloudProxyService} from '../service/CloudProxyService'
|
||||||
|
import {runtimeDiagnostics} from '../service/runtimeDiagnostics'
|
||||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
||||||
|
|
||||||
// Header (and query-param fallback for direct links such as object downloads)
|
// Header (and query-param fallback for direct links such as object downloads)
|
||||||
@@ -64,6 +65,12 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
return c.json(await svc(c).status(cloud, {includeServices}))
|
return c.json(await svc(c).status(cloud, {includeServices}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/diagnostics', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
return c.json(await runtimeDiagnostics(cloud, svc(c)))
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/:cloud/services/:service/status', async (c) => {
|
app.get('/:cloud/services/:service/status', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
const serviceType = c.req.param('service') as CloudServiceType
|
const serviceType = c.req.param('service') as CloudServiceType
|
||||||
@@ -385,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) => {
|
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
@@ -1150,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) => {
|
app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||||
const target = childTarget(c)
|
const target = childTarget(c)
|
||||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ import type {
|
|||||||
StorageObjectDownload,
|
StorageObjectDownload,
|
||||||
StorageObjectList,
|
StorageObjectList,
|
||||||
UpdateResourceInput,
|
UpdateResourceInput,
|
||||||
|
CreateUserPoolGroupInput,
|
||||||
|
UserPoolGroup,
|
||||||
|
UserPoolSignInTestInput,
|
||||||
|
UserPoolSignInTestResult,
|
||||||
|
UserPoolUserAction,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
import type {
|
import type {
|
||||||
ChildCollection,
|
ChildCollection,
|
||||||
@@ -664,6 +669,12 @@ export class CloudProxyService {
|
|||||||
return this.requireItems(cloud, service).listItems(resourceId, page)
|
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> {
|
async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||||
const items = this.requireItems(cloud, service)
|
const items = this.requireItems(cloud, service)
|
||||||
if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${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)
|
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[]> {
|
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
|
||||||
const adapter = this.requireAdapter(cloud, 'k8s')
|
const adapter = this.requireAdapter(cloud, 'k8s')
|
||||||
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import type {CloudProvider, CloudServiceDescriptor, CloudStatus} from '../cloud-spi/types'
|
||||||
|
import {runtimeDiagnostics} from './runtimeDiagnostics'
|
||||||
|
|
||||||
|
const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
|
||||||
|
const NOW = new Date('2026-10-23T10:30:15.000Z')
|
||||||
|
|
||||||
|
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
|
||||||
|
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
||||||
|
return {
|
||||||
|
status: async (cloud: CloudProvider) => ({
|
||||||
|
cloud, adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), error: null,
|
||||||
|
services: [{cloud, service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), latencyMs: 4, error: null, errorCode: null}],
|
||||||
|
...status,
|
||||||
|
}) as CloudStatus,
|
||||||
|
services: () => descriptors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtime(responses: Record<string, unknown>): typeof fetch {
|
||||||
|
return (async (input: string | URL | Request) => {
|
||||||
|
const path = new URL(String(input instanceof Request ? input.url : input)).pathname
|
||||||
|
return path in responses ? Response.json(responses[path]) : new Response('not found', {status: 404})
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = {now: () => NOW, startedAt: STARTED_AT, authMode: 'oidc' as const, activeSessions: () => 2}
|
||||||
|
|
||||||
|
describe('runtimeDiagnostics', () => {
|
||||||
|
test('reports the runtime version, every emulated service and its console pages', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy({}, [
|
||||||
|
descriptor('storage', 'S3'),
|
||||||
|
descriptor('loadbalancing', 'Load Balancers'),
|
||||||
|
descriptor('targetgroups', 'Target Groups'),
|
||||||
|
descriptor('scheduler', 'Scheduler', 'coming_soon'),
|
||||||
|
]), {
|
||||||
|
...fixed,
|
||||||
|
fetch: runtime({'/_floci/health': {version: '1.9.0', edition: 'compat', services: {s3: 'running', elasticloadbalancing: 'running', route53: 'running', scheduler: 'stopped'}}}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.runtime).toMatchObject({status: 'reachable', endpoint: 'http://floci:4566', version: '1.9.0', edition: 'compat', error: null})
|
||||||
|
expect(report.runtime.latencyMs).toBeNumber()
|
||||||
|
expect(report.emulatedServices).toEqual([
|
||||||
|
{name: 'elasticloadbalancing', state: 'running', consolePages: [
|
||||||
|
{service: 'loadbalancing', displayName: 'Load Balancers', route: '/cloud-explorer/aws/loadbalancing'},
|
||||||
|
{service: 'targetgroups', displayName: 'Target Groups', route: '/cloud-explorer/aws/targetgroups'},
|
||||||
|
]},
|
||||||
|
{name: 'route53', state: 'running', consolePages: []},
|
||||||
|
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
|
||||||
|
{name: 'scheduler', state: 'stopped', consolePages: []},
|
||||||
|
])
|
||||||
|
expect(report.consoleServices).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists the containers the runtime started for EC2 instances', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy(), {
|
||||||
|
...fixed,
|
||||||
|
fetch: runtime({'/_floci/diagnose': {ec2: {instances: [
|
||||||
|
{instanceId: 'i-0abc', containerStatus: 'running', sshHost: 'localhost', sshPort: 2201},
|
||||||
|
{instanceId: 'i-0def', containerStatus: 'stopped'},
|
||||||
|
{containerStatus: 'running'},
|
||||||
|
]}}}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.containers).toEqual([
|
||||||
|
{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201},
|
||||||
|
{kind: 'ec2-instance', resourceId: 'i-0def', status: 'stopped', sshHost: null, sshPort: null},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still returns a partial report when the runtime is down', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy({runtime: 'unavailable', error: 'Cannot reach Floci core at http://floci:4566', services: []}), {
|
||||||
|
...fixed,
|
||||||
|
fetch: (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.runtime).toEqual({status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null})
|
||||||
|
expect(report.emulatedServices).toBeNull()
|
||||||
|
expect(report.containers).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not query Floci core endpoints for other clouds', async () => {
|
||||||
|
const requested: string[] = []
|
||||||
|
const report = await runtimeDiagnostics('azure', proxy(), {
|
||||||
|
...fixed,
|
||||||
|
fetch: (async (input: string | URL | Request) => { requested.push(String(input)); return Response.json({}) }) as unknown as typeof fetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requested).toEqual([])
|
||||||
|
expect(report.emulatedServices).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes the console process itself', async () => {
|
||||||
|
const report = await runtimeDiagnostics('aws', proxy(), {...fixed, fetch: runtime({})})
|
||||||
|
|
||||||
|
expect(report.checkedAt).toBe('2026-10-23T10:30:15.000Z')
|
||||||
|
expect(report.console).toEqual({startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9015, authMode: 'oidc', activeSessions: 2})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {authStateSizes} from '../auth'
|
||||||
|
import type {
|
||||||
|
CloudProvider,
|
||||||
|
CloudServiceDescriptor,
|
||||||
|
CloudServiceType,
|
||||||
|
EmulatedServiceStatus,
|
||||||
|
RuntimeContainer,
|
||||||
|
RuntimeDiagnostics,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
import type {CloudProxyService} from './CloudProxyService'
|
||||||
|
import {awsEndpoint} from './runtimeProbe'
|
||||||
|
|
||||||
|
const DIAGNOSTICS_TIMEOUT_MS = 3_000
|
||||||
|
const processStartedAt = new Date()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floci core service ids mapped to the console services they back. Only the
|
||||||
|
* runtime's own ids are listed; whether a console page is actually available is
|
||||||
|
* still decided by the adapter registry through `services()`.
|
||||||
|
*/
|
||||||
|
const AWS_CONSOLE_SERVICES: Record<string, CloudServiceType[]> = {
|
||||||
|
apigateway: ['apigateway'],
|
||||||
|
appconfig: ['configuration'],
|
||||||
|
cloudformation: ['iac'],
|
||||||
|
codebuild: ['codebuild'],
|
||||||
|
codedeploy: ['codedeploy'],
|
||||||
|
codepipeline: ['codepipeline'],
|
||||||
|
'cognito-idp': ['userpools'],
|
||||||
|
dynamodb: ['nosql'],
|
||||||
|
ec2: ['compute', 'networking'],
|
||||||
|
ecr: ['ecr'],
|
||||||
|
ecs: ['ecs', 'fargate'],
|
||||||
|
eks: ['k8s'],
|
||||||
|
elasticache: ['elasticache'],
|
||||||
|
elasticloadbalancing: ['loadbalancing', 'targetgroups'],
|
||||||
|
email: ['email'],
|
||||||
|
events: ['events'],
|
||||||
|
iam: ['identity'],
|
||||||
|
kafka: ['msk'],
|
||||||
|
kafkaconnect: ['msk-connect'],
|
||||||
|
kinesis: ['kinesis'],
|
||||||
|
kms: ['kms'],
|
||||||
|
lambda: ['serverless'],
|
||||||
|
lightsail: ['lightsail'],
|
||||||
|
logs: ['logs'],
|
||||||
|
rds: ['database'],
|
||||||
|
'rds-data': ['database'],
|
||||||
|
s3: ['storage'],
|
||||||
|
secretsmanager: ['secrets'],
|
||||||
|
sns: ['notifications'],
|
||||||
|
sqs: ['messaging'],
|
||||||
|
ssm: ['parameters'],
|
||||||
|
states: ['workflows'],
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlociHealth = {services?: Record<string, unknown>; version?: unknown; edition?: unknown}
|
||||||
|
type FlociDiagnose = {ec2?: {instances?: Array<{instanceId?: unknown; containerStatus?: unknown; sshHost?: unknown; sshPort?: unknown}>}}
|
||||||
|
|
||||||
|
export type DiagnosticsDependencies = {
|
||||||
|
fetch?: typeof fetch
|
||||||
|
now?: () => Date
|
||||||
|
startedAt?: Date
|
||||||
|
authMode?: 'local' | 'oidc'
|
||||||
|
activeSessions?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the console can learn about a runtime in one call. Each source is
|
||||||
|
* optional: a runtime that is down, or one that does not publish a service list
|
||||||
|
* or container diagnostics, still yields a partial report rather than an error.
|
||||||
|
*/
|
||||||
|
export async function runtimeDiagnostics(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
proxy: Pick<CloudProxyService, 'status' | 'services'>,
|
||||||
|
dependencies: DiagnosticsDependencies = {},
|
||||||
|
): Promise<RuntimeDiagnostics> {
|
||||||
|
const fetcher = dependencies.fetch ?? globalThis.fetch
|
||||||
|
const now = dependencies.now?.() ?? new Date()
|
||||||
|
const startedAt = dependencies.startedAt ?? processStartedAt
|
||||||
|
const flociCore = cloud === 'aws'
|
||||||
|
|
||||||
|
const [status, health, diagnose] = await Promise.all([
|
||||||
|
proxy.status(cloud, {includeServices: true}),
|
||||||
|
flociCore ? fetchJson<FlociHealth>(fetcher, `${awsEndpoint()}/_floci/health`) : null,
|
||||||
|
flociCore ? fetchJson<FlociDiagnose>(fetcher, `${awsEndpoint()}/_floci/diagnose`) : null,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
cloud,
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
runtime: {
|
||||||
|
status: status.runtime,
|
||||||
|
endpoint: status.endpoint,
|
||||||
|
latencyMs: health?.latencyMs ?? null,
|
||||||
|
error: status.error,
|
||||||
|
version: stringOrNull(health?.body.version),
|
||||||
|
edition: stringOrNull(health?.body.edition),
|
||||||
|
},
|
||||||
|
emulatedServices: emulatedServices(health?.body.services, proxy.services(cloud)),
|
||||||
|
consoleServices: status.services ?? [],
|
||||||
|
containers: containers(diagnose?.body),
|
||||||
|
console: {
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
uptimeSeconds: Math.max(0, Math.floor((now.getTime() - startedAt.getTime()) / 1000)),
|
||||||
|
authMode: dependencies.authMode ?? (process.env.FLOCI_UI_AUTH_MODE === 'oidc' ? 'oidc' : 'local'),
|
||||||
|
activeSessions: (dependencies.activeSessions ?? (() => authStateSizes().sessions))(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emulatedServices(reported: Record<string, unknown> | undefined, descriptors: CloudServiceDescriptor[]): EmulatedServiceStatus[] | null {
|
||||||
|
if (!reported || typeof reported !== 'object') return null
|
||||||
|
const available = new Map(descriptors.filter((descriptor) => descriptor.availability === 'available').map((descriptor) => [descriptor.service, descriptor]))
|
||||||
|
return Object.entries(reported)
|
||||||
|
.map(([name, state]) => ({
|
||||||
|
name,
|
||||||
|
state: typeof state === 'string' ? state : 'unknown',
|
||||||
|
consolePages: (AWS_CONSOLE_SERVICES[name] ?? [])
|
||||||
|
.map((service) => available.get(service))
|
||||||
|
.filter((descriptor): descriptor is CloudServiceDescriptor => Boolean(descriptor))
|
||||||
|
.map(({service, displayName, route}) => ({service, displayName, route})),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function containers(diagnose: FlociDiagnose | undefined): RuntimeContainer[] | null {
|
||||||
|
const instances = diagnose?.ec2?.instances
|
||||||
|
if (!Array.isArray(instances)) return null
|
||||||
|
return instances
|
||||||
|
.filter((instance) => typeof instance.instanceId === 'string')
|
||||||
|
.map((instance) => ({
|
||||||
|
kind: 'ec2-instance' as const,
|
||||||
|
resourceId: instance.instanceId as string,
|
||||||
|
status: stringOrNull(instance.containerStatus),
|
||||||
|
sshHost: stringOrNull(instance.sshHost),
|
||||||
|
sshPort: typeof instance.sshPort === 'number' ? instance.sshPort : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(fetcher: typeof fetch, url: string): Promise<{body: T; latencyMs: number} | null> {
|
||||||
|
const startedAt = performance.now()
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, {signal: AbortSignal.timeout(DIAGNOSTICS_TIMEOUT_MS)})
|
||||||
|
if (!response.ok) return null
|
||||||
|
return {body: await response.json() as T, latencyMs: Math.round(performance.now() - startedAt)}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value ? value : null
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
|
|||||||
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||||
|
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
|
||||||
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
||||||
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ export default function App() {
|
|||||||
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
||||||
|
<Route path="/diagnostics" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||||
|
<Route path="/diagnostics/azure" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||||
|
<Route path="/diagnostics/gcp" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||||
|
<Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/>
|
||||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const apiEndpointKeys = {
|
|||||||
list: "clouds.list",
|
list: "clouds.list",
|
||||||
services: "clouds.services.list",
|
services: "clouds.services.list",
|
||||||
status: "clouds.status.get",
|
status: "clouds.status.get",
|
||||||
|
diagnostics: "clouds.diagnostics.get",
|
||||||
serviceStatus: "clouds.services.status.get",
|
serviceStatus: "clouds.services.status.get",
|
||||||
schema: "clouds.services.schema.get",
|
schema: "clouds.services.schema.get",
|
||||||
dependencies: "clouds.services.dependencies.get",
|
dependencies: "clouds.services.dependencies.get",
|
||||||
@@ -129,6 +130,16 @@ export const apiEndpointKeys = {
|
|||||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||||
moveTasks: "clouds.services.messaging.move-tasks",
|
moveTasks: "clouds.services.messaging.move-tasks",
|
||||||
},
|
},
|
||||||
|
userpools: {
|
||||||
|
userAction: "clouds.services.userpools.users.action",
|
||||||
|
userGroups: "clouds.services.userpools.users.groups",
|
||||||
|
groups: {
|
||||||
|
list: "clouds.services.userpools.groups.list",
|
||||||
|
create: "clouds.services.userpools.groups.create",
|
||||||
|
delete: "clouds.services.userpools.groups.delete",
|
||||||
|
},
|
||||||
|
signInTest: "clouds.services.userpools.sign-in-test",
|
||||||
|
},
|
||||||
k8s: {
|
k8s: {
|
||||||
nodegroups: {
|
nodegroups: {
|
||||||
list: "clouds.services.k8s.nodegroups.list",
|
list: "clouds.services.k8s.nodegroups.list",
|
||||||
@@ -153,6 +164,7 @@ export const apiEndpointKeys = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
flatItems: {
|
flatItems: {
|
||||||
|
get: "clouds.services.flatItems.get",
|
||||||
list: "clouds.services.flatItems.list",
|
list: "clouds.services.flatItems.list",
|
||||||
query: "clouds.services.flatItems.query",
|
query: "clouds.services.flatItems.query",
|
||||||
create: "clouds.services.flatItems.create",
|
create: "clouds.services.flatItems.create",
|
||||||
@@ -323,6 +335,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.diagnostics,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/diagnostics",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.serviceStatus,
|
apiEndpointKeys.clouds.serviceStatus,
|
||||||
{
|
{
|
||||||
@@ -821,6 +840,48 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.userAction,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/actions",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.userGroups,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/groups",
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.list,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.create,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/groups/:groupName",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.userpools.signInTest,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/userpools/resources/:id/sign-in-test",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||||
{
|
{
|
||||||
@@ -990,6 +1051,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.flatItems.get,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.flatItems.list,
|
apiEndpointKeys.clouds.flatItems.list,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CloudServiceStatus,
|
CloudServiceStatus,
|
||||||
CloudServiceType,
|
CloudServiceType,
|
||||||
CloudStatus,
|
CloudStatus,
|
||||||
|
RuntimeDiagnostics,
|
||||||
} from "@/types/cloud";
|
} from "@/types/cloud";
|
||||||
import type {
|
import type {
|
||||||
AppConfigConfigurationProfile,
|
AppConfigConfigurationProfile,
|
||||||
@@ -26,6 +27,11 @@ import type {
|
|||||||
DatabaseSnapshot,
|
DatabaseSnapshot,
|
||||||
KubernetesFargateProfile,
|
KubernetesFargateProfile,
|
||||||
KubernetesNodegroup,
|
KubernetesNodegroup,
|
||||||
|
CreateUserPoolGroupInput,
|
||||||
|
UserPoolGroup,
|
||||||
|
UserPoolSignInTestInput,
|
||||||
|
UserPoolSignInTestResult,
|
||||||
|
UserPoolUserAction,
|
||||||
LogsInsightsQueryInput,
|
LogsInsightsQueryInput,
|
||||||
LogsInsightsQueryResult,
|
LogsInsightsQueryResult,
|
||||||
LogsFilterInput,
|
LogsFilterInput,
|
||||||
@@ -86,6 +92,18 @@ export async function getCloudStatus(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCloudDiagnostics(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RuntimeDiagnostics> {
|
||||||
|
const res = await apiClient.call<RuntimeDiagnostics>(
|
||||||
|
apiEndpointKeys.clouds.diagnostics,
|
||||||
|
requestOptions(cloud, "cloud-proxy", { signal }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCloudServiceStatus(
|
export async function getCloudServiceStatus(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
service: CloudServiceType,
|
service: CloudServiceType,
|
||||||
@@ -1364,6 +1382,98 @@ export async function deleteFlatItem(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFlatItem(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
service: CloudServiceType,
|
||||||
|
resourceId: string,
|
||||||
|
itemId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChildItem> {
|
||||||
|
const res = await apiClient.call<ChildItem>(
|
||||||
|
apiEndpointKeys.clouds.flatItems.get,
|
||||||
|
requestOptions(cloud, service, { signal }),
|
||||||
|
{ cloud, service, id: resourceId, itemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserPoolUser(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
username: string,
|
||||||
|
input: UserPoolUserAction,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, UserPoolUserAction>(
|
||||||
|
apiEndpointKeys.clouds.userpools.userAction,
|
||||||
|
requestOptions(cloud, "userpools", { body: input }),
|
||||||
|
{ cloud, id: poolId, username },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserPoolUserGroups(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
username: string,
|
||||||
|
groups: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, { groups: string[] }>(
|
||||||
|
apiEndpointKeys.clouds.userpools.userGroups,
|
||||||
|
requestOptions(cloud, "userpools", { body: { groups } }),
|
||||||
|
{ cloud, id: poolId, username },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserPoolGroups(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<UserPoolGroup[]> {
|
||||||
|
const res = await apiClient.call<UserPoolGroup[]>(
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.list,
|
||||||
|
requestOptions(cloud, "userpools", { signal }),
|
||||||
|
{ cloud, id: poolId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserPoolGroup(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
input: CreateUserPoolGroupInput,
|
||||||
|
): Promise<UserPoolGroup> {
|
||||||
|
const res = await apiClient.call<UserPoolGroup, CreateUserPoolGroupInput>(
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.create,
|
||||||
|
requestOptions(cloud, "userpools", { body: input }),
|
||||||
|
{ cloud, id: poolId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserPoolGroup(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
groupName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||||
|
requestOptions(cloud, "userpools"),
|
||||||
|
{ cloud, id: poolId, groupName },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testUserPoolSignIn(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
poolId: string,
|
||||||
|
input: UserPoolSignInTestInput,
|
||||||
|
): Promise<UserPoolSignInTestResult> {
|
||||||
|
const res = await apiClient.call<UserPoolSignInTestResult, UserPoolSignInTestInput>(
|
||||||
|
apiEndpointKeys.clouds.userpools.signInTest,
|
||||||
|
requestOptions(cloud, "userpools", { body: input }),
|
||||||
|
{ cloud, id: poolId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function putFlatItem(
|
export async function putFlatItem(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
service: CloudServiceType,
|
service: CloudServiceType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
getCloudDiagnostics,
|
||||||
getCloudResource,
|
getCloudResource,
|
||||||
getCloudServiceStatus,
|
getCloudServiceStatus,
|
||||||
getCloudStatus,
|
getCloudStatus,
|
||||||
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
|
|||||||
clouds: ['clouds'] as const,
|
clouds: ['clouds'] as const,
|
||||||
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
||||||
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
||||||
|
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
|
||||||
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||||
['cloud-service-status', cloud, service] as const,
|
['cloud-service-status', cloud, service] as const,
|
||||||
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||||
@@ -53,6 +55,15 @@ export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInter
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full runtime report; polled slowly because it probes every console service. */
|
||||||
|
export function useCloudDiagnosticsQuery(cloud: CloudProvider) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: cloudQueryKeys.diagnostics(cloud),
|
||||||
|
queryFn: ({signal}) => getCloudDiagnostics(cloud, signal),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Health of the single service being viewed, rather than of the whole cloud. */
|
/** Health of the single service being viewed, rather than of the whole cloud. */
|
||||||
export function useCloudServiceStatusQuery(
|
export function useCloudServiceStatusQuery(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
|
|||||||
@@ -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 : ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
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'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
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)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const summary: CloudResource = {
|
||||||
|
id: 'us-east-1_8e65d229a', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||||
|
region: 'us-east-1', createdAt: null, metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function pool(signInWith: 'email' | 'username'): CloudResource {
|
||||||
|
return {...summary, metadata: {signInWith, appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alice = {
|
||||||
|
id: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', collectionId: null, timestamp: '2026-10-23T16:01:22.000Z', metadata: {},
|
||||||
|
body: {username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', email: 'alice@example.com', status: 'CONFIRMED', enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><CognitoUsersPanel resource={summary} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
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 () => {
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Copy Client ID'})).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('alice@example.com')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('CONFIRMED')).toBeInTheDocument()
|
||||||
|
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an email user who can sign in immediately', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||||
|
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})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a username user with a temporary password when asked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('username'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
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})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a user by its stored username', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete user alice@example.com'}))
|
||||||
|
|
||||||
|
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'})
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {RefreshCw, Trash2} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
|
||||||
|
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
|
||||||
|
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
|
||||||
|
import {CognitoUserDetail} from '@/components/CognitoUserDetail'
|
||||||
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppClient = {clientId: string; clientName: string}
|
||||||
|
|
||||||
|
export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [login, setLogin] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [permanent, setPermanent] = useState(true)
|
||||||
|
const [openUser, setOpenUser] = useState<string | null>(null)
|
||||||
|
const usersKey = ['cognito-users', accountId, resource?.id]
|
||||||
|
const groupsKey = ['cognito-groups', accountId, resource?.id]
|
||||||
|
|
||||||
|
// The table row is a list summary; sign-in method and app clients come from the full pool.
|
||||||
|
const poolQuery = useQuery({
|
||||||
|
queryKey: ['cognito-pool', accountId, resource?.id],
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'userpools', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: usersKey,
|
||||||
|
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: groupsKey,
|
||||||
|
queryFn: ({signal}) => listUserPoolGroups('aws', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogin('')
|
||||||
|
setPassword('')
|
||||||
|
setPermanent(true)
|
||||||
|
setOpenUser(null)
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
// The pool list carries each pool's user count, which the delete confirmation reads.
|
||||||
|
const refreshUsers = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({queryKey: usersKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'userpools']}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username'
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
|
||||||
|
...(signInWithEmail ? {email: login.trim()} : {username: login.trim()}),
|
||||||
|
password,
|
||||||
|
permanent,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setLogin('')
|
||||||
|
setPassword('')
|
||||||
|
return refreshUsers()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
|
||||||
|
onSuccess: (_result, username) => {
|
||||||
|
if (openUser === username) setOpenUser(null)
|
||||||
|
return refreshUsers()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a user pool</h3><p>Choose a user pool to manage its app clients and users.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = usersQuery.data?.items ?? []
|
||||||
|
const appClients = appClientsOf(poolQuery.data)
|
||||||
|
const pending = createMutation.isPending || deleteMutation.isPending
|
||||||
|
const error = poolQuery.error ?? usersQuery.error ?? createMutation.error ?? deleteMutation.error
|
||||||
|
const loginLabel = signInWithEmail ? 'Email' : 'Username'
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Users in ${resource.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
|
||||||
|
</div>
|
||||||
|
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
|
||||||
|
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => (
|
||||||
|
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
|
||||||
|
|
||||||
|
<CognitoGroupsSection poolId={resource.id} groupsQuery={groupsQuery} groupsKey={groupsKey} runtimeReachable={runtimeReachable}/>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Users</p><span className="muted">{users.length} {users.length === 1 ? 'user' : 'users'} in {resource.name}</span></div>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch(), groupsQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
<form className="dynamic-form" aria-label="Create user" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" type={signInWithEmail ? 'email' : 'text'} value={login} onChange={(event) => setLogin(event.target.value)} autoComplete="off" disabled={pending} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={pending} required/></label>
|
||||||
|
<label className="dynamic-field dynamic-field--span"><span><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={pending}/> User can sign in now</span><small className="muted">When unchecked the password is temporary and must be changed at first sign-in.</small></label>
|
||||||
|
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !login.trim() || !password || pending}>{createMutation.isPending ? 'Creating' : 'Create user'}</button></div>
|
||||||
|
</form>
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
{usersQuery.isLoading && <div className="muted padded">Loading users</div>}
|
||||||
|
{usersQuery.isSuccess && users.length === 0 && <div className="empty compact"><h3>No users</h3><p>Create a user to sign in to apps that use this pool.</p></div>}
|
||||||
|
{users.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>{loginLabel}</th><th>Status</th><th>Enabled</th><th>Created</th><th/></tr></thead><tbody>{users.map((user) => {
|
||||||
|
const name = stringValue(user.body.email) || stringValue(user.body.username)
|
||||||
|
return <tr key={user.id}>
|
||||||
|
<td><span className="copy-value">{name}<CopyValueButton value={name} label={loginLabel}/></span></td>
|
||||||
|
<td><code>{stringValue(user.body.status) || '—'}</code></td>
|
||||||
|
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
||||||
|
<td>{formatDateTime(user.timestamp) ?? '—'}</td>
|
||||||
|
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openUser === user.id} aria-label={`Manage user ${name}`} onClick={() => setOpenUser(openUser === user.id ? null : user.id)}>Manage</button><button className="icon-btn danger" type="button" title="Delete user" aria-label={`Delete user ${name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(user.id)}><Trash2 size={14}/></button></td>
|
||||||
|
</tr>
|
||||||
|
})}</tbody></table></div>}
|
||||||
|
{openUser && <CognitoUserDetail key={openUser} poolId={resource.id} username={openUser} groups={groupsQuery.data ?? []} usersKey={usersKey} runtimeReachable={runtimeReachable} onClose={() => setOpenUser(null)}/>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function appClientsOf(pool?: CloudResource): AppClient[] {
|
||||||
|
const clients = pool?.metadata.appClients
|
||||||
|
if (!Array.isArray(clients)) return []
|
||||||
|
return clients.filter((client): client is AppClient => typeof client?.clientId === 'string' && client.clientId !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
|
|||||||
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
||||||
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
||||||
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
||||||
|
import { describeDeleteImpact } from "@/lib/deleteImpact";
|
||||||
import { getPath } from "@/lib/resourcePath";
|
import { getPath } from "@/lib/resourcePath";
|
||||||
import {
|
import {
|
||||||
capabilityEnabled,
|
capabilityEnabled,
|
||||||
@@ -63,6 +64,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
|
|||||||
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||||
|
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||||
@@ -573,12 +575,14 @@ export function DynamicResourceView({
|
|||||||
const resources = resourcesQuery.data ?? [];
|
const resources = resourcesQuery.data ?? [];
|
||||||
const canCreate = schema.actions.includes("create");
|
const canCreate = schema.actions.includes("create");
|
||||||
const canDelete = schema.actions.includes("delete");
|
const canDelete = schema.actions.includes("delete");
|
||||||
|
const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems);
|
||||||
const isAllSelected =
|
const isAllSelected =
|
||||||
resources.length > 0 &&
|
resources.length > 0 &&
|
||||||
resources.every((r) => selectedItems.some((s) => s.id === r.id));
|
resources.every((r) => selectedItems.some((s) => s.id === r.id));
|
||||||
|
// Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot.
|
||||||
const activeSelected =
|
const activeSelected =
|
||||||
selected?.cloud === cloud && selected.service === service
|
selected?.cloud === cloud && selected.service === service
|
||||||
? selected
|
? resources.find((resource) => resource.id === selected.id) ?? selected
|
||||||
: undefined;
|
: undefined;
|
||||||
const runtimeReachable = cloudStatus?.runtime === "reachable";
|
const runtimeReachable = cloudStatus?.runtime === "reachable";
|
||||||
const resourceCapabilityInputs =
|
const resourceCapabilityInputs =
|
||||||
@@ -743,6 +747,9 @@ export function DynamicResourceView({
|
|||||||
{canDelete && (
|
{canDelete && (
|
||||||
deleteConfirm ? (
|
deleteConfirm ? (
|
||||||
<>
|
<>
|
||||||
|
{deleteImpactText && (
|
||||||
|
<span className="delete-impact" role="status">{deleteImpactText}</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="button danger"
|
className="button danger"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -969,6 +976,9 @@ export function DynamicResourceView({
|
|||||||
{service === "loadbalancing" && cloud === "aws" && (
|
{service === "loadbalancing" && cloud === "aws" && (
|
||||||
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "userpools" && cloud === "aws" && (
|
||||||
|
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
{service === "compute" && (
|
{service === "compute" && (
|
||||||
<ComputePanel
|
<ComputePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
||||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
||||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import theCloud from '../../../../TheCloud.png'
|
import theCloud from '../../../../TheCloud.png'
|
||||||
import {useTheme} from '@/lib/useTheme'
|
import {useTheme} from '@/lib/useTheme'
|
||||||
@@ -184,6 +184,7 @@ export function Layout() {
|
|||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span className="nav-label">General</span>
|
<span className="nav-label">General</span>
|
||||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
||||||
|
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
|
||||||
</div>
|
</div>
|
||||||
<CloudServiceNav collapsed={collapsed}/>
|
<CloudServiceNav collapsed={collapsed}/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -356,6 +357,6 @@ function TopbarSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
||||||
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/)
|
const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/)
|
||||||
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {ResourceTable} from './ResourceTable'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import type {ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
|
const schema: ServiceSchema = {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'userpools',
|
||||||
|
displayName: 'Cognito',
|
||||||
|
fields: [],
|
||||||
|
actions: ['list', 'delete'],
|
||||||
|
filters: [],
|
||||||
|
columns: [{name: 'name', label: 'Name'}],
|
||||||
|
deleteImpact: [
|
||||||
|
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
|
||||||
|
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool: CloudResource = {
|
||||||
|
id: 'us-east-1_abc', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||||
|
region: 'us-east-1', createdAt: null, metadata: {userCount: 2, appClients: [{clientId: 'c1'}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the row delete confirmation says what else is deleted', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
render(<ResourceTable schema={schema} resources={[pool]} onSelect={vi.fn()} onDelete={onDelete}/>)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete orders-users'}))
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('This also deletes 2 users and 1 app client.')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Confirm'}))
|
||||||
|
expect(onDelete).toHaveBeenCalledWith(pool)
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {Pencil, Trash2} from 'lucide-react';
|
import {Pencil, Trash2} from 'lucide-react';
|
||||||
|
import {describeDeleteImpact} from '@/lib/deleteImpact';
|
||||||
import type {CloudResource} from '@/types/resource';
|
import type {CloudResource} from '@/types/resource';
|
||||||
import type {ServiceSchema} from '@/types/schema';
|
import type {ServiceSchema} from '@/types/schema';
|
||||||
import {getPath} from '@/lib/resourcePath';
|
import {getPath} from '@/lib/resourcePath';
|
||||||
@@ -140,6 +141,7 @@ export function ResourceTable({
|
|||||||
className="table-confirm-actions"
|
className="table-confirm-actions"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<DeleteImpact schema={schema} resource={resource} />
|
||||||
<button
|
<button
|
||||||
className="button danger compact"
|
className="button danger compact"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -207,3 +209,8 @@ function CopyableColumnValue({resource, column}: {resource: CloudResource; colum
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeleteImpact({schema, resource}: {schema: ServiceSchema; resource: CloudResource}) {
|
||||||
|
const text = describeDeleteImpact(schema.deleteImpact, [resource]);
|
||||||
|
return text ? <span className="delete-impact" role="status">{text}</span> : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Table2,
|
Table2,
|
||||||
|
UsersRound,
|
||||||
Workflow,
|
Workflow,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -29,6 +30,7 @@ import appConfigIcon from '@/assets/aws-services/appconfig.svg'
|
|||||||
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
|
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
|
||||||
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
||||||
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
||||||
|
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||||
@@ -84,6 +86,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
logs: ScrollText,
|
logs: ScrollText,
|
||||||
iam: ShieldCheck,
|
iam: ShieldCheck,
|
||||||
kms: Lock,
|
kms: Lock,
|
||||||
|
userpools: UsersRound,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
parameters: SlidersHorizontal,
|
parameters: SlidersHorizontal,
|
||||||
configuration: SlidersHorizontal,
|
configuration: SlidersHorizontal,
|
||||||
@@ -127,6 +130,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
secrets: secretsManagerIcon,
|
secrets: secretsManagerIcon,
|
||||||
serverless: lambdaIcon,
|
serverless: lambdaIcon,
|
||||||
storage: s3Icon,
|
storage: s3Icon,
|
||||||
|
userpools: cognitoIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -779,6 +779,47 @@ pre,
|
|||||||
position: relative;
|
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 {
|
.topbar-status:empty + .account-switcher {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -4261,6 +4302,213 @@ pre,
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cognito-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics .table-panel {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-filters,
|
||||||
|
.diagnostics-filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-filters .input {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-service-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-service {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-page-link {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 11px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-page-link:hover {
|
||||||
|
border-color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts div {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts dt {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-facts dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.diagnostics-filters .input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.console-provider-banner {
|
.console-provider-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {expect, test} from 'vitest'
|
||||||
|
import {describeDeleteImpact} from './deleteImpact'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const impact = [
|
||||||
|
{path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'},
|
||||||
|
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||||
|
]
|
||||||
|
|
||||||
|
function pool(users: unknown, clients: unknown[]): CloudResource {
|
||||||
|
return {id: 'p', name: 'p', cloud: 'aws', service: 'userpools', type: 'user-pool', region: null, createdAt: null, metadata: {estimatedNumberOfUsers: users, appClients: clients}}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sums numbers and list lengths across the selection', () => {
|
||||||
|
expect(describeDeleteImpact(impact, [pool(2, [{}]), pool(1, [])])).toBe('This also deletes 3 users and 1 app client.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves out zero and unreadable counts', () => {
|
||||||
|
expect(describeDeleteImpact(impact, [pool(0, [{}, {}])])).toBe('This also deletes 2 app clients.')
|
||||||
|
expect(describeDeleteImpact(impact, [pool(null, [])])).toBeNull()
|
||||||
|
expect(describeDeleteImpact(undefined, [pool(5, [])])).toBeNull()
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {getPath} from '@/lib/resourcePath'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import type {ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "This also deletes 2 users and 1 app client." for the delete confirmation,
|
||||||
|
* summed over the selected resources. A count is a number at the path, or the
|
||||||
|
* length of a list there; anything else, and zero counts, are left out.
|
||||||
|
*/
|
||||||
|
export function describeDeleteImpact(impact: ServiceSchema['deleteImpact'], resources: CloudResource[]): string | null {
|
||||||
|
if (!impact?.length) return null
|
||||||
|
|
||||||
|
const parts = impact.flatMap(({path, singular, plural}) => {
|
||||||
|
const total = resources.reduce((sum, resource) => {
|
||||||
|
const value = getPath(resource, path)
|
||||||
|
if (Array.isArray(value)) return sum + value.length
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? sum + value : sum
|
||||||
|
}, 0)
|
||||||
|
return total > 0 ? [`${total} ${total === 1 ? singular : plural}`] : []
|
||||||
|
})
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
|
||||||
|
const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
|
||||||
|
return `This also deletes ${list}.`
|
||||||
|
}
|
||||||
@@ -2,9 +2,17 @@ import {useEffect, useState} from 'react'
|
|||||||
|
|
||||||
export type Theme = 'dark' | 'light' | 'cyber'
|
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 {
|
function getStored(): Theme {
|
||||||
const v = localStorage.getItem('floci-theme')
|
try {
|
||||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
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() {
|
export function useTheme() {
|
||||||
@@ -12,7 +20,11 @@ export function useTheme() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
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])
|
}, [theme])
|
||||||
|
|
||||||
return {theme, setTheme}
|
return {theme, setTheme}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter, Route, Routes} from 'react-router-dom'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {DiagnosticsPage} from './DiagnosticsPage'
|
||||||
|
import type {RuntimeDiagnostics} from '@/types/cloud'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({getCloudDiagnostics: vi.fn()}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
|
||||||
|
const healthy: RuntimeDiagnostics = {
|
||||||
|
cloud: 'aws',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
runtime: {status: 'reachable', endpoint: 'http://floci:4566', latencyMs: 12, error: null, version: '1.9.0', edition: 'community'},
|
||||||
|
emulatedServices: [
|
||||||
|
{name: 'route53', state: 'running', consolePages: []},
|
||||||
|
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
|
||||||
|
{name: 'sqs', state: 'stopped', consolePages: [{service: 'messaging', displayName: 'SQS', route: '/cloud-explorer/aws/messaging'}]},
|
||||||
|
],
|
||||||
|
consoleServices: [
|
||||||
|
{cloud: 'aws', service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 4, error: null, errorCode: null},
|
||||||
|
{cloud: 'aws', service: 'messaging', adapterRegistered: true, runtime: 'unavailable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 9, error: 'Service is not running', errorCode: 'RUNTIME_UNAVAILABLE'},
|
||||||
|
],
|
||||||
|
containers: [{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201}],
|
||||||
|
console: {startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9_015, authMode: 'oidc', activeSessions: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(path = '/diagnostics/aws') {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<Routes><Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/></Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('summarises the runtime and lists failing console services first', async () => {
|
||||||
|
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const summary = await screen.findByRole('region', {name: 'Runtime summary'})
|
||||||
|
expect(within(summary).getByText('Reachable')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('http://floci:4566 · 12 ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1.9.0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||||
|
|
||||||
|
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)')
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: 'i-0abc'})).toHaveAttribute('href', '/cloud-explorer/aws/compute?resource=i-0abc')
|
||||||
|
expect(screen.getByText('localhost:2201')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 h 30 min')).toBeInTheDocument()
|
||||||
|
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)
|
||||||
|
renderPage()
|
||||||
|
const section = await screen.findByRole('region', {name: 'Emulated services'})
|
||||||
|
|
||||||
|
await user.click(within(section).getByRole('button', {name: 'No console page'}))
|
||||||
|
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['route53'])
|
||||||
|
|
||||||
|
await user.click(within(section).getByRole('button', {name: 'Not running'}))
|
||||||
|
expect(within(section).getByRole('link', {name: 'SQS'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
|
||||||
|
expect(within(section).getAllByRole('listitem')).toHaveLength(1)
|
||||||
|
|
||||||
|
await user.click(within(section).getByRole('button', {name: 'All'}))
|
||||||
|
await user.type(within(section).getByRole('searchbox'), 's3')
|
||||||
|
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['s3S3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows what is known when the runtime is down', async () => {
|
||||||
|
api.getCloudDiagnostics.mockResolvedValue({
|
||||||
|
...healthy,
|
||||||
|
runtime: {status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null},
|
||||||
|
emulatedServices: null,
|
||||||
|
consoleServices: [],
|
||||||
|
containers: null,
|
||||||
|
} satisfies RuntimeDiagnostics)
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Unavailable')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Cannot reach Floci core at http://floci:4566')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('Not reported by the runtime')).toHaveLength(2)
|
||||||
|
expect(screen.queryByRole('region', {name: 'Emulated services'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('This runtime does not publish container diagnostics.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import {useMemo, useState} from 'react'
|
||||||
|
import {Activity, RefreshCw} from 'lucide-react'
|
||||||
|
import {Link, Navigate, useParams} from 'react-router-dom'
|
||||||
|
import {useCloudDiagnosticsQuery} from '@/api/queries/cloudQueries'
|
||||||
|
import {formatDateTime, formatRelativeTime} from '@/lib/format'
|
||||||
|
import type {CloudProvider, CloudServiceStatus, EmulatedServiceStatus, RuntimeDiagnostics} from '@/types/cloud'
|
||||||
|
|
||||||
|
type ServiceFilter = 'all' | 'not-running' | 'with-page' | 'without-page'
|
||||||
|
|
||||||
|
const SERVICE_FILTERS: Array<{id: ServiceFilter; label: string}> = [
|
||||||
|
{id: 'all', label: 'All'},
|
||||||
|
{id: 'not-running', label: 'Not running'},
|
||||||
|
{id: 'with-page', label: 'Has console page'},
|
||||||
|
{id: 'without-page', label: 'No console page'},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function DiagnosticsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const cloud = normalizeCloud(params.cloud)
|
||||||
|
const query = useCloudDiagnosticsQuery(cloud ?? 'aws')
|
||||||
|
|
||||||
|
if (!cloud) return <Navigate to="/diagnostics/aws" replace/>
|
||||||
|
|
||||||
|
const report = query.data
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header cloud-explorer-header">
|
||||||
|
<div className="page-title">
|
||||||
|
<Activity size={20}/>
|
||||||
|
<div>
|
||||||
|
<h1>Diagnostics</h1>
|
||||||
|
<p className="muted">{report ? `Checked ${formatRelativeTime(report.checkedAt) ?? 'just now'}` : 'Runtime, services and console health'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="button" type="button" disabled={query.isFetching} onClick={() => void query.refetch()}>
|
||||||
|
<RefreshCw size={14}/>{query.isFetching ? 'Checking' : 'Re-check'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content diagnostics">
|
||||||
|
{query.isLoading && <p className="muted padded">Running diagnostics</p>}
|
||||||
|
{query.error && <p className="error-text">Could not load diagnostics: {query.error.message}</p>}
|
||||||
|
{report && <DiagnosticsReport report={report}/>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
|
||||||
|
const emulated = report.emulatedServices
|
||||||
|
const notRunning = emulated?.filter((service) => service.state !== 'running').length ?? 0
|
||||||
|
const covered = emulated?.filter((service) => service.consolePages.length > 0).length ?? 0
|
||||||
|
const reachable = report.runtime.status === 'reachable'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="console-summary" aria-label="Runtime summary">
|
||||||
|
<div className={`summary-tile ${reachable ? 'ready' : 'unavailable'}`}>
|
||||||
|
<span>Runtime</span>
|
||||||
|
<strong>{reachable ? 'Reachable' : 'Unavailable'}</strong>
|
||||||
|
<small>{report.runtime.endpoint ?? 'No endpoint'}{report.runtime.latencyMs !== null ? ` · ${report.runtime.latencyMs} ms` : ''}</small>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<span>Version</span>
|
||||||
|
<strong>{report.runtime.version ?? 'Unknown'}</strong>
|
||||||
|
<small>{report.runtime.edition ? `${report.runtime.edition} edition` : 'Not reported by the runtime'}</small>
|
||||||
|
</div>
|
||||||
|
<div className={`summary-tile ${notRunning > 0 ? 'pending' : ''}`}>
|
||||||
|
<span>Emulated services</span>
|
||||||
|
<strong>{emulated ? emulated.length : '—'}</strong>
|
||||||
|
<small>{emulated ? (notRunning > 0 ? `${notRunning} not running` : 'All running') : 'Not reported by the runtime'}</small>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<span>Console coverage</span>
|
||||||
|
<strong>{emulated ? `${covered} / ${emulated.length}` : `${report.consoleServices.length}`}</strong>
|
||||||
|
<small>{emulated ? 'Emulated services with a console page' : 'Console services'}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{report.runtime.error && <p className="error-text">{report.runtime.error}</p>}
|
||||||
|
|
||||||
|
<ConsoleServicesTable services={report.consoleServices}/>
|
||||||
|
{emulated && <EmulatedServices services={emulated}/>}
|
||||||
|
<ContainersTable report={report}/>
|
||||||
|
<ConsoleProcess report={report}/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||||
|
const [showResponding, setShowResponding] = useState(false)
|
||||||
|
const 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>
|
||||||
|
<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>}
|
||||||
|
{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}>
|
||||||
|
<td><code>{service.service}</code></td>
|
||||||
|
<td><span className={`status ${statusClass(service.runtime)}`}>{statusLabel(service.runtime)}</span></td>
|
||||||
|
<td>{service.latencyMs === null ? '—' : `${service.latencyMs} ms`}</td>
|
||||||
|
<td>{service.error ? <span className="error-text compact-text">{service.error}{service.errorCode ? ` (${service.errorCode})` : ''}</span> : <span className="muted">—</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmulatedServices({services}: {services: EmulatedServiceStatus[]}) {
|
||||||
|
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const term = search.trim().toLowerCase()
|
||||||
|
return services.filter((service) => {
|
||||||
|
if (term && !service.name.includes(term) && !service.consolePages.some((page) => page.displayName.toLowerCase().includes(term))) return false
|
||||||
|
if (filter === 'not-running') return service.state !== 'running'
|
||||||
|
if (filter === 'with-page') return service.consolePages.length > 0
|
||||||
|
if (filter === 'without-page') return service.consolePages.length === 0
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [services, filter, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-emulated-services">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-emulated-services">Emulated services</h2>
|
||||||
|
<div className="diagnostics-filters">
|
||||||
|
<div className="diagnostics-filter-group" role="group" aria-label="Filter emulated services">
|
||||||
|
{SERVICE_FILTERS.map((option) => (
|
||||||
|
<button key={option.id} className={`button compact ${filter === option.id ? 'primary' : ''}`} type="button" aria-pressed={filter === option.id} onClick={() => setFilter(option.id)}>{option.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input className="input" type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Filter services" aria-label="Filter emulated services by name"/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{visible.length === 0
|
||||||
|
? <p className="muted padded">No emulated services match this filter.</p>
|
||||||
|
: <ul className="diagnostics-service-grid">{visible.map((service) => (
|
||||||
|
<li key={service.name} className="diagnostics-service">
|
||||||
|
<span className={`dot ${service.state === 'running' ? 'healthy' : 'unavailable'}`} aria-hidden="true"/>
|
||||||
|
<code>{service.name}</code>
|
||||||
|
{service.state !== 'running' && <span className="muted">{service.state}</span>}
|
||||||
|
{service.consolePages.map((page) => <Link key={page.service} className="diagnostics-page-link" to={page.route}>{page.displayName}</Link>)}
|
||||||
|
</li>
|
||||||
|
))}</ul>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainersTable({report}: {report: RuntimeDiagnostics}) {
|
||||||
|
const containers = report.containers
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-containers">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-containers">Runtime containers</h2>
|
||||||
|
<span className="muted">Containers started for EC2 instances</span>
|
||||||
|
</header>
|
||||||
|
{containers === null && <p className="muted padded">This runtime does not publish container diagnostics.</p>}
|
||||||
|
{containers?.length === 0 && <p className="muted padded">No EC2 instance containers are running.</p>}
|
||||||
|
{containers && containers.length > 0 && <table className="table">
|
||||||
|
<thead><tr><th>Instance</th><th>Container</th><th>SSH</th></tr></thead>
|
||||||
|
<tbody>{containers.map((container) => (
|
||||||
|
<tr key={container.resourceId}>
|
||||||
|
<td><Link to={`/cloud-explorer/${report.cloud}/compute?resource=${encodeURIComponent(container.resourceId)}`}><code>{container.resourceId}</code></Link></td>
|
||||||
|
<td>{container.status ?? '—'}</td>
|
||||||
|
<td>{container.sshPort ? <code>{container.sshHost ?? 'localhost'}:{container.sshPort}</code> : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleProcess({report}: {report: RuntimeDiagnostics}) {
|
||||||
|
return (
|
||||||
|
<section className="table-panel" aria-labelledby="diagnostics-console">
|
||||||
|
<header className="diagnostics-section-header">
|
||||||
|
<h2 id="diagnostics-console">Console</h2>
|
||||||
|
</header>
|
||||||
|
<dl className="diagnostics-facts">
|
||||||
|
<div><dt>Uptime</dt><dd>{formatUptime(report.console.uptimeSeconds)}</dd></div>
|
||||||
|
<div><dt>Started</dt><dd>{formatDateTime(report.console.startedAt) ?? report.console.startedAt}</dd></div>
|
||||||
|
<div><dt>Sign-in</dt><dd>{report.console.authMode === 'oidc' ? 'OIDC single sign-on' : 'Local administrator'}</dd></div>
|
||||||
|
<div><dt>Active sessions</dt><dd>{report.console.activeSessions}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(totalSeconds: number): string {
|
||||||
|
const days = Math.floor(totalSeconds / 86_400)
|
||||||
|
const hours = Math.floor((totalSeconds % 86_400) / 3_600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3_600) / 60)
|
||||||
|
if (days > 0) return `${days} d ${hours} h`
|
||||||
|
if (hours > 0) return `${hours} h ${minutes} min`
|
||||||
|
if (minutes > 0) return `${minutes} min`
|
||||||
|
return `${totalSeconds} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(runtime: CloudServiceStatus['runtime']): string {
|
||||||
|
if (runtime === 'reachable') return 'healthy'
|
||||||
|
if (runtime === 'coming_soon') return 'degraded'
|
||||||
|
return 'unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(runtime: CloudServiceStatus['runtime']): string {
|
||||||
|
if (runtime === 'reachable') return 'Reachable'
|
||||||
|
if (runtime === 'coming_soon') return 'Not registered'
|
||||||
|
return 'Unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloud(value?: string): CloudProvider | null {
|
||||||
|
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||||
|
}
|
||||||
@@ -83,3 +83,39 @@ export interface CloudServiceStatus {
|
|||||||
/** Mapped error code, so "not implemented" reads differently from "down". */
|
/** Mapped error code, so "not implemented" reads differently from "down". */
|
||||||
errorCode: string | null
|
errorCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmulatedServiceStatus {
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContainer {
|
||||||
|
kind: 'ec2-instance'
|
||||||
|
resourceId: string
|
||||||
|
status: string | null
|
||||||
|
sshHost: string | null
|
||||||
|
sshPort: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDiagnostics {
|
||||||
|
cloud: CloudProvider
|
||||||
|
checkedAt: string
|
||||||
|
runtime: {
|
||||||
|
status: RuntimeReachability
|
||||||
|
endpoint: string | null
|
||||||
|
latencyMs: number | null
|
||||||
|
error: string | null
|
||||||
|
version: string | null
|
||||||
|
edition: string | null
|
||||||
|
}
|
||||||
|
emulatedServices: EmulatedServiceStatus[] | null
|
||||||
|
consoleServices: CloudServiceStatus[]
|
||||||
|
containers: RuntimeContainer[] | null
|
||||||
|
console: {
|
||||||
|
startedAt: string
|
||||||
|
uptimeSeconds: number
|
||||||
|
authMode: 'local' | 'oidc'
|
||||||
|
activeSessions: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -274,3 +274,35 @@ export interface AppConfigDeployment {
|
|||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserPoolUserAction =
|
||||||
|
| {action: 'enable'}
|
||||||
|
| {action: 'disable'}
|
||||||
|
| {action: 'confirm'}
|
||||||
|
| {action: 'resetPassword'}
|
||||||
|
| {action: 'signOut'}
|
||||||
|
| {action: 'setPassword'; password: string; permanent?: boolean}
|
||||||
|
|
||||||
|
export interface UserPoolGroup {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
precedence: number | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserPoolGroupInput {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
precedence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPoolSignInTestInput {
|
||||||
|
clientId: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserPoolSignInTestResult =
|
||||||
|
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||||
|
| {outcome: 'challenge'; challengeName: string}
|
||||||
|
| {outcome: 'rejected'; code: string; message: string}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type KubernetesActionName =
|
|||||||
| 'createFargateProfile'
|
| 'createFargateProfile'
|
||||||
| 'deleteFargateProfile'
|
| 'deleteFargateProfile'
|
||||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||||
|
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
export interface CapabilitySchema<TAction extends string> {
|
export interface CapabilitySchema<TAction extends string> {
|
||||||
@@ -86,8 +87,11 @@ export interface ServiceSchema {
|
|||||||
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
|
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
|
||||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||||
|
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||||
}
|
}
|
||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
columns: TableColumnSchema[]
|
columns: TableColumnSchema[]
|
||||||
updateFields?: FieldSchema[]
|
updateFields?: FieldSchema[]
|
||||||
|
/** Counts read from the resource that the delete confirmation names. */
|
||||||
|
deleteImpact?: Array<{path: string; singular: string; plural: string}>
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+220
-4
@@ -34,6 +34,9 @@ importers:
|
|||||||
'@aws-sdk/client-codepipeline':
|
'@aws-sdk/client-codepipeline':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1144.0
|
version: 3.1144.0
|
||||||
|
'@aws-sdk/client-cognito-identity-provider':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1156.0
|
||||||
'@aws-sdk/client-dynamodb':
|
'@aws-sdk/client-dynamodb':
|
||||||
specifier: ^3.1088.0
|
specifier: ^3.1088.0
|
||||||
version: 3.1112.0
|
version: 3.1112.0
|
||||||
@@ -318,6 +321,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==}
|
resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-cognito-identity-provider@3.1156.0':
|
||||||
|
resolution: {integrity: sha512-R+fVyawDRELzLTMKYIrzGtqku6OULvRnk1SQDR1QQhyVnvZXmJDNuKlcvChl0pEFllpO69Rce9TiZwzMAe/2oQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-dynamodb@3.1112.0':
|
'@aws-sdk/client-dynamodb@3.1112.0':
|
||||||
resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==}
|
resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -441,6 +448,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
|
resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/core@3.978.5':
|
||||||
|
resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.54':
|
'@aws-sdk/credential-provider-env@3.972.54':
|
||||||
resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==}
|
resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -465,6 +476,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
|
resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-env@3.972.76':
|
||||||
|
resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.56':
|
'@aws-sdk/credential-provider-http@3.972.56':
|
||||||
resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==}
|
resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -489,6 +504,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
|
resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-http@3.972.78':
|
||||||
|
resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.972.61':
|
'@aws-sdk/credential-provider-ini@3.972.61':
|
||||||
resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==}
|
resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -509,6 +528,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
|
resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-ini@3.973.21':
|
||||||
|
resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||||
resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==}
|
resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -537,6 +560,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
|
resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-login@3.972.83':
|
||||||
|
resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-node@3.972.63':
|
'@aws-sdk/credential-provider-node@3.972.63':
|
||||||
resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==}
|
resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -565,6 +592,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
|
resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-node@3.972.88':
|
||||||
|
resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.54':
|
'@aws-sdk/credential-provider-process@3.972.54':
|
||||||
resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==}
|
resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -589,6 +620,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
|
resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-process@3.972.76':
|
||||||
|
resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||||
resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==}
|
resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -609,6 +644,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
|
resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-sso@3.973.20':
|
||||||
|
resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||||
resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==}
|
resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -637,6 +676,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
|
resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-web-identity@3.972.82':
|
||||||
|
resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/dynamodb-codec@3.973.43':
|
'@aws-sdk/dynamodb-codec@3.973.43':
|
||||||
resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==}
|
resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -697,6 +740,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
|
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/nested-clients@3.997.50':
|
||||||
|
resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/signature-v4-multi-region@3.996.38':
|
'@aws-sdk/signature-v4-multi-region@3.996.38':
|
||||||
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
|
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -709,6 +756,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
|
resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/signature-v4-multi-region@3.996.49':
|
||||||
|
resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1080.0':
|
'@aws-sdk/token-providers@3.1080.0':
|
||||||
resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==}
|
resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -733,6 +784,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
|
resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/token-providers@3.1155.0':
|
||||||
|
resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/types@3.973.15':
|
'@aws-sdk/types@3.973.15':
|
||||||
resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==}
|
resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -753,6 +808,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
|
resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/types@3.975.0':
|
||||||
|
resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/util-dynamodb@3.996.9':
|
'@aws-sdk/util-dynamodb@3.996.9':
|
||||||
resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==}
|
resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -1220,6 +1279,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==}
|
resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/core@3.36.2':
|
||||||
|
resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.4.14':
|
'@smithy/credential-provider-imds@4.4.14':
|
||||||
resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==}
|
resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -1300,6 +1363,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
|
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@smithy/signature-v4@5.7.4':
|
||||||
|
resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@smithy/types@4.15.1':
|
'@smithy/types@4.15.1':
|
||||||
resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==}
|
resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -2798,10 +2865,10 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/checksums@3.1000.13':
|
'@aws-sdk/checksums@3.1000.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.1
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.974.6
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.35.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-api-gateway@3.1096.0':
|
'@aws-sdk/client-api-gateway@3.1096.0':
|
||||||
@@ -2894,6 +2961,17 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-cognito-identity-provider@3.1156.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/credential-provider-node': 3.972.88
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
|
'@smithy/node-http-handler': 4.12.1
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/client-dynamodb@3.1112.0':
|
'@aws-sdk/client-dynamodb@3.1112.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.977.8
|
'@aws-sdk/core': 3.977.8
|
||||||
@@ -3249,6 +3327,17 @@ snapshots:
|
|||||||
bowser: 2.14.1
|
bowser: 2.14.1
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/core@3.978.5':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@aws-sdk/xml-builder': 3.972.41
|
||||||
|
'@aws/lambda-invoke-store': 0.3.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/signature-v4': 5.7.4
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
bowser: 2.14.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.54':
|
'@aws-sdk/credential-provider-env@3.972.54':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3297,6 +3386,14 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-env@3.972.76':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.56':
|
'@aws-sdk/credential-provider-http@3.972.56':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3357,6 +3454,16 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-http@3.972.78':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
|
'@smithy/node-http-handler': 4.12.1
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.972.61':
|
'@aws-sdk/credential-provider-ini@3.972.61':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3437,6 +3544,22 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-ini@3.973.21':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/credential-provider-env': 3.972.76
|
||||||
|
'@aws-sdk/credential-provider-http': 3.972.78
|
||||||
|
'@aws-sdk/credential-provider-login': 3.972.83
|
||||||
|
'@aws-sdk/credential-provider-process': 3.972.76
|
||||||
|
'@aws-sdk/credential-provider-sso': 3.973.20
|
||||||
|
'@aws-sdk/credential-provider-web-identity': 3.972.82
|
||||||
|
'@aws-sdk/nested-clients': 3.997.50
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/credential-provider-imds': 4.5.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3507,6 +3630,15 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-login@3.972.83':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/nested-clients': 3.997.50
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-node@3.972.63':
|
'@aws-sdk/credential-provider-node@3.972.63':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/credential-provider-env': 3.972.54
|
'@aws-sdk/credential-provider-env': 3.972.54
|
||||||
@@ -3605,6 +3737,20 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-node@3.972.88':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/credential-provider-env': 3.972.76
|
||||||
|
'@aws-sdk/credential-provider-http': 3.972.78
|
||||||
|
'@aws-sdk/credential-provider-ini': 3.973.21
|
||||||
|
'@aws-sdk/credential-provider-process': 3.972.76
|
||||||
|
'@aws-sdk/credential-provider-sso': 3.973.20
|
||||||
|
'@aws-sdk/credential-provider-web-identity': 3.972.82
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/credential-provider-imds': 4.5.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.54':
|
'@aws-sdk/credential-provider-process@3.972.54':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3653,6 +3799,14 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-process@3.972.76':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3703,6 +3857,16 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-sso@3.973.20':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/nested-clients': 3.997.50
|
||||||
|
'@aws-sdk/token-providers': 3.1155.0
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3767,6 +3931,15 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/credential-provider-web-identity@3.972.82':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/nested-clients': 3.997.50
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/dynamodb-codec@3.973.43':
|
'@aws-sdk/dynamodb-codec@3.973.43':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.0
|
||||||
@@ -3899,6 +4072,17 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/nested-clients@3.997.50':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/signature-v4-multi-region': 3.996.49
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
|
'@smithy/node-http-handler': 4.12.1
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/signature-v4-multi-region@3.996.38':
|
'@aws-sdk/signature-v4-multi-region@3.996.38':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.974.5
|
||||||
@@ -3920,6 +4104,13 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/signature-v4-multi-region@3.996.49':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/signature-v4': 5.7.4
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/token-providers@3.1080.0':
|
'@aws-sdk/token-providers@3.1080.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.1
|
||||||
@@ -3974,6 +4165,15 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/token-providers@3.1155.0':
|
||||||
|
dependencies:
|
||||||
|
'@aws-sdk/core': 3.978.5
|
||||||
|
'@aws-sdk/nested-clients': 3.997.50
|
||||||
|
'@aws-sdk/types': 3.975.0
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/types@3.973.15':
|
'@aws-sdk/types@3.973.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.17.2
|
||||||
@@ -3999,6 +4199,11 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/types@3.975.0':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)':
|
'@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/client-dynamodb': 3.1112.0
|
'@aws-sdk/client-dynamodb': 3.1112.0
|
||||||
@@ -4496,6 +4701,11 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/core@3.36.2':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.4.14':
|
'@smithy/credential-provider-imds@4.4.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.33.3
|
||||||
@@ -4615,6 +4825,12 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@smithy/signature-v4@5.7.4':
|
||||||
|
dependencies:
|
||||||
|
'@smithy/core': 3.36.2
|
||||||
|
'@smithy/types': 4.19.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/types@4.15.1':
|
'@smithy/types@4.15.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user