Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bafe799d00 | |||
| 43f4cae9b5 | |||
| b4c2cadf5c | |||
| 8803498498 | |||
| f6b9a62811 | |||
| 8b166d86d4 | |||
| f956d977ca | |||
| 9f38313e59 |
@@ -1,4 +1,6 @@
|
||||
(() => {
|
||||
// Shared with the console (useTheme.ts, index.html) so one choice applies everywhere.
|
||||
const THEME_KEY = 'floci-theme';
|
||||
const root = document.documentElement;
|
||||
const buttons = document.querySelectorAll('.tg button');
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
@@ -6,7 +8,7 @@
|
||||
function setTheme(theme) {
|
||||
root.setAttribute('data-theme', theme);
|
||||
try {
|
||||
localStorage.setItem('thecloud-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
} catch (ignored) {
|
||||
// Theme persistence is optional when browser storage is unavailable.
|
||||
}
|
||||
@@ -15,7 +17,7 @@
|
||||
|
||||
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
try {
|
||||
const savedTheme = localStorage.getItem('thecloud-theme');
|
||||
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
|
||||
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||
initialTheme = savedTheme;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import {createHmac} from 'node:crypto'
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
AdminAddUserToGroupCommand,
|
||||
AdminConfirmSignUpCommand,
|
||||
AdminDisableUserCommand,
|
||||
AdminListGroupsForUserCommand,
|
||||
AdminRemoveUserFromGroupCommand,
|
||||
AdminUserGlobalSignOutCommand,
|
||||
CreateGroupCommand,
|
||||
DeleteGroupCommand,
|
||||
DescribeUserPoolClientCommand,
|
||||
InitiateAuthCommand,
|
||||
ListGroupsCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminGetUserCommand,
|
||||
@@ -14,7 +26,7 @@ import {
|
||||
type CognitoIdentityProviderClient,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
@@ -109,12 +121,25 @@ describe('AwsUserPoolsAdapter', () => {
|
||||
signInWith: 'email',
|
||||
passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol',
|
||||
estimatedNumberOfUsers: 1,
|
||||
userCount: 0,
|
||||
mfaConfiguration: 'OFF',
|
||||
deletionProtection: 'INACTIVE',
|
||||
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
|
||||
})
|
||||
})
|
||||
|
||||
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
|
||||
if (command instanceof ListUsersCommand) return command.input.PaginationToken ? {Users: [alice]} : {Users: [alice, alice], PaginationToken: 'page-2'}
|
||||
return {}
|
||||
})
|
||||
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
|
||||
|
||||
expect(resource?.metadata.userCount).toBe(3)
|
||||
expect(resource?.metadata.estimatedNumberOfUsers).toBe(0)
|
||||
})
|
||||
|
||||
test('summarises a relaxed password policy without character rules', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}}
|
||||
@@ -255,3 +280,110 @@ describe('AwsUserPoolsAdapter users', () => {
|
||||
expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username})
|
||||
})
|
||||
})
|
||||
|
||||
function jwt(claims: Record<string, unknown>): string {
|
||||
return ['{"alg":"RS256"}', JSON.stringify(claims), 'sig'].map((part) => Buffer.from(part).toString('base64url')).join('.')
|
||||
}
|
||||
|
||||
describe('AwsUserPoolsAdapter user actions and groups', () => {
|
||||
test('reads a user with the groups it belongs to', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof AdminGetUserCommand) return {Username: alice.Username, UserAttributes: alice.Attributes, UserStatus: 'CONFIRMED', Enabled: true, UserCreateDate: CREATED}
|
||||
if (command instanceof AdminListGroupsForUserCommand) return {Groups: [{GroupName: 'admins'}]}
|
||||
return {}
|
||||
})
|
||||
const user = await new AwsUserPoolsAdapter(client).items.getItem!(POOL_ID, alice.Username)
|
||||
|
||||
expect(user?.body.groups).toEqual(['admins'])
|
||||
expect((sent[1] as AdminListGroupsForUserCommand).input).toMatchObject({UserPoolId: POOL_ID, Username: alice.Username})
|
||||
})
|
||||
|
||||
test('runs each user action as its Admin call', async () => {
|
||||
const {client, sent} = stubCognito(() => ({}))
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'disable'})
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'signOut'})
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword', password: ' Passw0rd! '})
|
||||
|
||||
expect(sent[0]).toBeInstanceOf(AdminDisableUserCommand)
|
||||
expect(sent[1]).toBeInstanceOf(AdminConfirmSignUpCommand)
|
||||
expect(sent[2]).toBeInstanceOf(AdminUserGlobalSignOutCommand)
|
||||
expect((sent[3] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: 'bob', Password: ' Passw0rd! ', Permanent: true})
|
||||
})
|
||||
|
||||
test('rejects unknown actions and reports a refused one as a conflict, never a 401', async () => {
|
||||
const {client} = stubCognito(() => { throw awsError('NotAuthorizedException') })
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'explode'} as never)).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword'} as never)).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
|
||||
test('sets a user\'s groups by adding and removing only the difference', async () => {
|
||||
const {client, sent} = stubCognito((command) => command instanceof AdminListGroupsForUserCommand ? {Groups: [{GroupName: 'staff'}, {GroupName: 'beta'}]} : {})
|
||||
await new AwsUserPoolsAdapter(client).setUserPoolUserGroups(POOL_ID, 'bob', ['staff', 'admins'])
|
||||
|
||||
const changes = sent.slice(1)
|
||||
expect(changes).toHaveLength(2)
|
||||
expect(changes[0]).toBeInstanceOf(AdminAddUserToGroupCommand)
|
||||
expect((changes[0] as AdminAddUserToGroupCommand).input.GroupName).toBe('admins')
|
||||
expect(changes[1]).toBeInstanceOf(AdminRemoveUserFromGroupCommand)
|
||||
expect((changes[1] as AdminRemoveUserFromGroupCommand).input.GroupName).toBe('beta')
|
||||
})
|
||||
|
||||
test('lists, creates and deletes groups', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof ListGroupsCommand) return {Groups: [{GroupName: 'admins', Description: 'Full access', Precedence: 0, CreationDate: CREATED}]}
|
||||
if (command instanceof CreateGroupCommand) return {Group: {GroupName: command.input.GroupName, Precedence: command.input.Precedence, CreationDate: CREATED}}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
expect(await adapter.listUserPoolGroups(POOL_ID)).toEqual([{name: 'admins', description: 'Full access', precedence: 0, createdAt: CREATED.toISOString()}])
|
||||
expect(await adapter.createUserPoolGroup(POOL_ID, {name: 'editors', precedence: 5})).toMatchObject({name: 'editors', precedence: 5})
|
||||
await adapter.deleteUserPoolGroup(POOL_ID, 'editors')
|
||||
|
||||
expect((sent[1] as CreateGroupCommand).input).toEqual({UserPoolId: POOL_ID, GroupName: 'editors', Precedence: 5})
|
||||
expect(sent[2]).toBeInstanceOf(DeleteGroupCommand)
|
||||
})
|
||||
|
||||
test('validates a group before creating it', async () => {
|
||||
const {client, sent} = stubCognito(() => ({}))
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'has space'})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'ok', precedence: -1})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('test sign-in returns decoded claims, adding SECRET_HASH for a client with a secret', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: {ClientId: 'client-1', ClientSecret: 'shh'}}
|
||||
if (command instanceof InitiateAuthCommand) return {AuthenticationResult: {ExpiresIn: 3600, IdToken: jwt({sub: 's-1', email: 'alice@example.com', token_use: 'id'}), AccessToken: jwt({sub: 's-1', token_use: 'access'})}}
|
||||
return {}
|
||||
})
|
||||
const result = await new AwsUserPoolsAdapter(client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'alice@example.com', password: 'Passw0rd!'})
|
||||
|
||||
expect(result).toEqual({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: 's-1', email: 'alice@example.com', token_use: 'id'}, accessToken: {sub: 's-1', token_use: 'access'}})
|
||||
const auth = (sent[1] as InitiateAuthCommand).input
|
||||
expect(auth.AuthFlow).toBe('USER_PASSWORD_AUTH')
|
||||
expect(auth.AuthParameters?.SECRET_HASH).toBe(createHmac('sha256', 'shh').update('alice@example.comclient-1').digest('base64'))
|
||||
})
|
||||
|
||||
test('test sign-in reports a wrong password or a challenge as its outcome', async () => {
|
||||
const refusing = stubCognito((command) => {
|
||||
if (command instanceof InitiateAuthCommand) throw Object.assign(awsError('NotAuthorizedException'), {message: 'Incorrect username or password.'})
|
||||
return {UserPoolClient: {ClientId: 'client-1'}}
|
||||
})
|
||||
expect(await new AwsUserPoolsAdapter(refusing.client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'a', password: 'x'}))
|
||||
.toEqual({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
|
||||
expect((refusing.sent[1] as InitiateAuthCommand).input.AuthParameters?.SECRET_HASH).toBeUndefined()
|
||||
|
||||
const challenging = stubCognito((command) => command instanceof InitiateAuthCommand ? {ChallengeName: 'NEW_PASSWORD_REQUIRED'} : {UserPoolClient: {}})
|
||||
expect(await new AwsUserPoolsAdapter(challenging.client).testUserPoolSignIn(POOL_ID, {clientId: 'c', username: 'a', password: 'x'}))
|
||||
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import {createHmac} from 'node:crypto'
|
||||
import {
|
||||
AdminAddUserToGroupCommand,
|
||||
AdminConfirmSignUpCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminDisableUserCommand,
|
||||
AdminEnableUserCommand,
|
||||
AdminGetUserCommand,
|
||||
AdminListGroupsForUserCommand,
|
||||
AdminRemoveUserFromGroupCommand,
|
||||
AdminResetUserPasswordCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
AdminUserGlobalSignOutCommand,
|
||||
CreateGroupCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateUserPoolCommand,
|
||||
DeleteGroupCommand,
|
||||
DeleteUserPoolCommand,
|
||||
DescribeUserPoolClientCommand,
|
||||
DescribeUserPoolCommand,
|
||||
InitiateAuthCommand,
|
||||
ListGroupsCommand,
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
type AttributeType,
|
||||
type CognitoIdentityProviderClient,
|
||||
type GroupType,
|
||||
type PasswordPolicyType,
|
||||
type UserPoolClientDescription,
|
||||
type UserPoolDescriptionType,
|
||||
@@ -19,8 +34,9 @@ import {
|
||||
type UserType,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
USER_POOL_GROUP_NAME_PATTERN,
|
||||
USER_POOL_NAME_PATTERN,
|
||||
USER_POOL_PASSWORD_POLICIES,
|
||||
USER_POOL_SIGN_IN,
|
||||
@@ -30,12 +46,19 @@ import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
CreateUserPoolGroupInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/** Cognito caps every list page at 60. */
|
||||
const COGNITO_PAGE_LIMIT = 60
|
||||
/** Counting stops here so a very large pool cannot stall the pool list. */
|
||||
const USER_COUNT_CAP = 1000
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/**
|
||||
@@ -51,6 +74,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
* symbol, matching AWS.
|
||||
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
|
||||
* password is permanent, so users created here can sign in immediately.
|
||||
* - `EstimatedNumberOfUsers` stays 0 however many users exist (AWS also only
|
||||
* updates it periodically), so `userCount` is counted with `ListUsers`.
|
||||
* - An admin action the user's state does not allow (confirming a confirmed
|
||||
* user) is `NotAuthorizedException`, surfaced here as a conflict because the
|
||||
* console caller is authorised and a 401 would end the console session.
|
||||
*
|
||||
* Users are the pool's child items. The app client is created with the pool, as
|
||||
* the console's create flow does, and its id is surfaced in the pool metadata.
|
||||
@@ -86,11 +114,12 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
const [pool, clients] = await Promise.all([
|
||||
const [pool, clients, userCount] = await Promise.all([
|
||||
this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})),
|
||||
this.listClients(id),
|
||||
this.countUsers(id),
|
||||
])
|
||||
return pool.UserPool ? toResource(pool.UserPool, clients) : null
|
||||
return pool.UserPool ? toResource(pool.UserPool, clients, userCount) : null
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null
|
||||
throw error
|
||||
@@ -140,6 +169,137 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id}))
|
||||
}
|
||||
|
||||
async updateUserPoolUser(poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
|
||||
const target = {UserPoolId: poolId, Username: username}
|
||||
const action = (input as {action?: unknown} | null)?.action
|
||||
const command = (() => {
|
||||
switch (action) {
|
||||
case 'enable': return new AdminEnableUserCommand(target)
|
||||
case 'disable': return new AdminDisableUserCommand(target)
|
||||
case 'confirm': return new AdminConfirmSignUpCommand(target)
|
||||
case 'resetPassword': return new AdminResetUserPasswordCommand(target)
|
||||
case 'signOut': return new AdminUserGlobalSignOutCommand(target)
|
||||
case 'setPassword': {
|
||||
const {password, permanent} = input as {password?: unknown; permanent?: unknown}
|
||||
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
|
||||
if (permanent !== undefined && typeof permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
|
||||
return new AdminSetUserPasswordCommand({...target, Password: password, Permanent: permanent ?? true})
|
||||
}
|
||||
default:
|
||||
throw new ValidationError('action must be one of enable, disable, confirm, resetPassword, signOut, setPassword')
|
||||
}
|
||||
})()
|
||||
try {
|
||||
await this.cognito.send(command as AdminEnableUserCommand)
|
||||
} catch (error) {
|
||||
throw asConflict(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes the user's groups exactly `groups`, adding and removing only the difference. */
|
||||
async setUserPoolUserGroups(poolId: string, username: string, groups: string[]): Promise<void> {
|
||||
if (!Array.isArray(groups) || groups.some((group) => typeof group !== 'string' || !group)) {
|
||||
throw new ValidationError('groups must be a list of group names')
|
||||
}
|
||||
const wanted = new Set(groups)
|
||||
const current = new Set(await this.listGroupsForUser(poolId, username))
|
||||
for (const group of wanted) {
|
||||
if (!current.has(group)) await this.cognito.send(new AdminAddUserToGroupCommand({UserPoolId: poolId, Username: username, GroupName: group}))
|
||||
}
|
||||
for (const group of current) {
|
||||
if (!wanted.has(group)) await this.cognito.send(new AdminRemoveUserFromGroupCommand({UserPoolId: poolId, Username: username, GroupName: group}))
|
||||
}
|
||||
}
|
||||
|
||||
async listUserPoolGroups(poolId: string): Promise<UserPoolGroup[]> {
|
||||
const groups: GroupType[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await this.cognito.send(new ListGroupsCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
groups.push(...(res.Groups ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
return groups.flatMap(toGroup)
|
||||
}
|
||||
|
||||
async createUserPoolGroup(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> {
|
||||
const name = requiredString(input?.name, 'name')
|
||||
if (!new RegExp(USER_POOL_GROUP_NAME_PATTERN, 'u').test(name)) throw new ValidationError('name must be up to 128 characters with no spaces')
|
||||
const description = optionalString(input.description, 'description')
|
||||
if (description && description.length > 2048) throw new ValidationError('description must be 2048 characters or fewer')
|
||||
const precedence = input.precedence
|
||||
if (precedence !== undefined && precedence !== null && (!Number.isInteger(precedence) || precedence < 0)) {
|
||||
throw new ValidationError('precedence must be a whole number of 0 or more')
|
||||
}
|
||||
|
||||
const res = await this.cognito.send(new CreateGroupCommand({
|
||||
UserPoolId: poolId,
|
||||
GroupName: name,
|
||||
...(description ? {Description: description} : {}),
|
||||
...(typeof precedence === 'number' ? {Precedence: precedence} : {}),
|
||||
}))
|
||||
const group = res.Group ? toGroup(res.Group)[0] : undefined
|
||||
if (!group) throw new RuntimeError(`Cognito did not return the group ${name} created in ${poolId}`)
|
||||
return group
|
||||
}
|
||||
|
||||
async deleteUserPoolGroup(poolId: string, groupName: string): Promise<void> {
|
||||
await this.cognito.send(new DeleteGroupCommand({UserPoolId: poolId, GroupName: groupName}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in the way an app would (`InitiateAuth`, `USER_PASSWORD_AUTH`) and
|
||||
* returns the decoded claims. A refused sign-in is a result, not an error:
|
||||
* the wrong password is exactly what this is for trying.
|
||||
*/
|
||||
async testUserPoolSignIn(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> {
|
||||
const clientId = requiredString(input?.clientId, 'clientId')
|
||||
const username = requiredString(input.username, 'username')
|
||||
if (typeof input.password !== 'string' || !input.password) throw new ValidationError('password is required')
|
||||
|
||||
const client = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: clientId}))
|
||||
const secret = client.UserPoolClient?.ClientSecret
|
||||
try {
|
||||
const res = await this.cognito.send(new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: 'USER_PASSWORD_AUTH',
|
||||
AuthParameters: {
|
||||
USERNAME: username,
|
||||
PASSWORD: input.password,
|
||||
...(secret ? {SECRET_HASH: secretHash(username, clientId, secret)} : {}),
|
||||
},
|
||||
}))
|
||||
if (res.ChallengeName) return {outcome: 'challenge', challengeName: res.ChallengeName}
|
||||
const result = res.AuthenticationResult
|
||||
if (!result?.IdToken || !result.AccessToken) throw new RuntimeError('Cognito returned no tokens')
|
||||
return {
|
||||
outcome: 'signed-in',
|
||||
expiresIn: result.ExpiresIn ?? null,
|
||||
idToken: decodeJwtPayload(result.IdToken),
|
||||
accessToken: decodeJwtPayload(result.AccessToken),
|
||||
}
|
||||
} catch (error) {
|
||||
const name = (error as {name?: string} | null)?.name ?? ''
|
||||
if (SIGN_IN_REFUSALS.has(name)) return {outcome: 'rejected', code: name, message: (error as Error).message}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
|
||||
return listGroupNamesForUser(this.cognito, poolId, username)
|
||||
}
|
||||
|
||||
private async countUsers(poolId: string): Promise<number> {
|
||||
let count = 0
|
||||
let token: string | undefined
|
||||
do {
|
||||
const res = await this.cognito.send(new ListUsersCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(token ? {PaginationToken: token} : {})}))
|
||||
count += res.Users?.length ?? 0
|
||||
token = res.PaginationToken || undefined
|
||||
} while (token && count < USER_COUNT_CAP)
|
||||
return Math.min(count, USER_COUNT_CAP)
|
||||
}
|
||||
|
||||
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
|
||||
const clients: UserPoolClientDescription[] = []
|
||||
let nextToken: string | undefined
|
||||
@@ -169,7 +329,9 @@ class UserStore implements ItemStoreAdapter {
|
||||
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
|
||||
try {
|
||||
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
|
||||
return toUserItem(user.Username, user.UserAttributes, user)[0] ?? null
|
||||
const item = toUserItem(user.Username, user.UserAttributes, user)[0]
|
||||
if (!item) return null
|
||||
return {...item, body: {...item.body, groups: await listGroupNamesForUser(this.cognito, poolId, item.id)}}
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null
|
||||
throw error
|
||||
@@ -215,6 +377,53 @@ class UserStore implements ItemStoreAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Refusals a test sign-in reports as its outcome rather than failing. */
|
||||
const SIGN_IN_REFUSALS = new Set([
|
||||
'NotAuthorizedException',
|
||||
'UserNotFoundException',
|
||||
'UserNotConfirmedException',
|
||||
'PasswordResetRequiredException',
|
||||
'InvalidParameterException',
|
||||
])
|
||||
|
||||
async function listGroupNamesForUser(cognito: CognitoIdentityProviderClient, poolId: string, username: string): Promise<string[]> {
|
||||
const names: string[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await cognito.send(new AdminListGroupsForUserCommand({UserPoolId: poolId, Username: username, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
names.push(...(res.Groups ?? []).flatMap((group) => group.GroupName ? [group.GroupName] : []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
return names
|
||||
}
|
||||
|
||||
function toGroup(group: GroupType): UserPoolGroup[] {
|
||||
if (!group.GroupName) return []
|
||||
return [{
|
||||
name: group.GroupName,
|
||||
description: group.Description ?? null,
|
||||
precedence: group.Precedence ?? null,
|
||||
createdAt: group.CreationDate ? group.CreationDate.toISOString() : null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Required by app clients that have a secret: Base64(HMAC-SHA256(secret, username + clientId)). */
|
||||
function secretHash(username: string, clientId: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(username + clientId).digest('base64')
|
||||
}
|
||||
|
||||
/** The token came straight from Cognito, so only its payload is read; nothing is trusted from it. */
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) throw new RuntimeError('Cognito returned a malformed token')
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function asConflict(error: unknown): unknown {
|
||||
if ((error as {name?: string} | null)?.name !== 'NotAuthorizedException') return error
|
||||
return new ConflictError((error as Error).message || 'The user is not in a state that allows this')
|
||||
}
|
||||
|
||||
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
|
||||
if (!pool.Id) return []
|
||||
return [{
|
||||
@@ -230,7 +439,7 @@ function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
|
||||
}]
|
||||
}
|
||||
|
||||
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource {
|
||||
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], userCount: number): CloudResource {
|
||||
const id = pool.Id ?? ''
|
||||
const policy = pool.Policies?.PasswordPolicy
|
||||
return {
|
||||
@@ -247,6 +456,7 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): C
|
||||
signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username',
|
||||
passwordPolicy: policy ? describePasswordPolicy(policy) : null,
|
||||
estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null,
|
||||
userCount,
|
||||
mfaConfiguration: pool.MfaConfiguration ?? null,
|
||||
deletionProtection: pool.DeletionProtection ?? null,
|
||||
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('cognito errors carry HTTP 400 and keep their meaning', () => {
|
||||
['UserNotFoundException', 404],
|
||||
['UsernameExistsException', 409],
|
||||
['AliasExistsException', 409],
|
||||
['GroupExistsException', 409],
|
||||
['InvalidPasswordException', 400],
|
||||
]
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ const CONFLICT_NAMES = new Set([
|
||||
'DeleteConflictException',
|
||||
'UsernameExistsException',
|
||||
'AliasExistsException',
|
||||
'GroupExistsException',
|
||||
])
|
||||
|
||||
const ACCESS_DENIED_NAMES = new Set([
|
||||
|
||||
@@ -171,6 +171,13 @@ export type KubernetesActionName =
|
||||
| 'listFargateProfiles'
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type UserPoolActionName =
|
||||
| 'updateUser'
|
||||
| 'setUserGroups'
|
||||
| 'listGroups'
|
||||
| 'createGroup'
|
||||
| 'deleteGroup'
|
||||
| 'testSignIn'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -214,6 +221,7 @@ export interface ServiceSchema {
|
||||
objectActions?: CapabilitySchema<ObjectActionName>[]
|
||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
// Child levels advertise separately from the resource level because a
|
||||
// store can be readable at the leaf and writable at the collection —
|
||||
// CloudWatch Logs creates streams but cannot delete an event.
|
||||
@@ -223,6 +231,11 @@ export interface ServiceSchema {
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
updateFields?: FieldSchema[]
|
||||
/**
|
||||
* Counts read from the resource (`path` as in columns) that the delete
|
||||
* confirmation names, so deleting a pool says it also deletes its users.
|
||||
*/
|
||||
deleteImpact?: {path: string; singular: string; plural: string}[]
|
||||
}
|
||||
|
||||
export type KnownResourceType =
|
||||
@@ -411,6 +424,40 @@ export interface SqsMoveTaskResult {
|
||||
taskHandle: string
|
||||
}
|
||||
|
||||
/** What an admin can do to one user pool user, each a single Admin* call. */
|
||||
export type UserPoolUserAction =
|
||||
| {action: 'enable'}
|
||||
| {action: 'disable'}
|
||||
| {action: 'confirm'}
|
||||
| {action: 'resetPassword'}
|
||||
| {action: 'signOut'}
|
||||
| {action: 'setPassword'; password: string; permanent?: boolean}
|
||||
|
||||
export interface UserPoolGroup {
|
||||
name: string
|
||||
description: string | null
|
||||
precedence: number | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserPoolGroupInput {
|
||||
name: string
|
||||
description?: string
|
||||
precedence?: number
|
||||
}
|
||||
|
||||
export interface UserPoolSignInTestInput {
|
||||
clientId: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
|
||||
export type UserPoolSignInTestResult =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
| {outcome: 'rejected'; code: string; message: string}
|
||||
|
||||
/** Provider-neutral representation of a managed Kubernetes node pool. */
|
||||
export interface KubernetesNodegroup {
|
||||
id: string
|
||||
@@ -917,6 +964,12 @@ export interface CloudServiceAdapter {
|
||||
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
|
||||
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
|
||||
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
|
||||
updateUserPoolUser?(poolId: string, username: string, input: UserPoolUserAction): Promise<void>
|
||||
setUserPoolUserGroups?(poolId: string, username: string, groups: string[]): Promise<void>
|
||||
listUserPoolGroups?(poolId: string): Promise<UserPoolGroup[]>
|
||||
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
|
||||
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
|
||||
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||
clearEmailInbox?(): Promise<void>
|
||||
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from
|
||||
export const USER_POOL_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$'
|
||||
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
|
||||
export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] as const
|
||||
/** Cognito's group name rule: 1–128 letters, marks, symbols, digits or punctuation (no whitespace). Needs the `u` flag. */
|
||||
export const USER_POOL_GROUP_NAME_PATTERN = '^[\\p{L}\\p{M}\\p{S}\\p{N}\\p{P}]{1,128}$'
|
||||
|
||||
const userPoolColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
@@ -72,9 +74,21 @@ export function awsUserPoolsSchema(): ServiceSchema {
|
||||
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
userPoolActions: [
|
||||
{name: 'updateUser', label: 'Enable, disable, confirm, sign out or reset a user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'setUserGroups', label: 'Change a user\'s groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'listGroups', label: 'List groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: userPoolFilters,
|
||||
columns: userPoolColumns,
|
||||
deleteImpact: [
|
||||
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
|
||||
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName} from './cloud-spi/types'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -47,6 +47,15 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
||||
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
||||
}
|
||||
|
||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||
updateUser: 'updateUserPoolUser',
|
||||
setUserGroups: 'setUserPoolUserGroups',
|
||||
listGroups: 'listUserPoolGroups',
|
||||
createGroup: 'createUserPoolGroup',
|
||||
deleteGroup: 'deleteUserPoolGroup',
|
||||
testSignIn: 'testUserPoolSignIn',
|
||||
}
|
||||
|
||||
const registry = createCloudAdapterRegistry()
|
||||
|
||||
function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> {
|
||||
@@ -121,6 +130,15 @@ describe('registered adapters honour their schema', () => {
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const userPoolClaims = (adapter.schema().capabilities?.userPoolActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of userPoolClaims) {
|
||||
const method = METHOD_FOR_USER_POOL_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
test(`${label} child collections match their advertised capabilities`, () => {
|
||||
@@ -133,6 +151,7 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.objectActions ?? []),
|
||||
...(adapter.schema().capabilities?.databaseActions ?? []),
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ import {awsEksSchema} from '../cloud-spi/eksSchema'
|
||||
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||
@@ -694,6 +695,58 @@ describe('cloud schema routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('reads one flat item, answering 404 when it is gone', async () => {
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'userpools',
|
||||
schema: awsUserPoolsSchema,
|
||||
items: {
|
||||
listItems: async () => ({items: [], nextCursor: null}),
|
||||
getItem: async (_poolId, username) => username === 'bob@example.com'
|
||||
? {id: 'u-1', collectionId: null, timestamp: null, body: {email: username, groups: ['admins']}, metadata: {}}
|
||||
: null,
|
||||
},
|
||||
})])
|
||||
|
||||
const found = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/bob%40example.com')
|
||||
const missing = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/nobody')
|
||||
|
||||
expect(found.status).toBe(200)
|
||||
expect((await found.json()).body.groups).toEqual(['admins'])
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test('routes Cognito user actions, groups and test sign-in to the adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'userpools',
|
||||
schema: awsUserPoolsSchema,
|
||||
updateUserPoolUser: async (poolId, username, input) => { calls.push(['action', poolId, username, input]) },
|
||||
setUserPoolUserGroups: async (poolId, username, groups) => { calls.push(['groups', poolId, username, groups]) },
|
||||
listUserPoolGroups: async () => [{name: 'admins', description: null, precedence: 0, createdAt: null}],
|
||||
createUserPoolGroup: async (_poolId, input) => ({name: input.name, description: null, precedence: null, createdAt: null}),
|
||||
deleteUserPoolGroup: async (poolId, groupName) => { calls.push(['deleteGroup', poolId, groupName]) },
|
||||
testUserPoolSignIn: async () => ({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}),
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x'
|
||||
|
||||
expect((await app.request(`${base}/users/bob%40example.com/actions`, json('POST', {action: 'disable'}))).status).toBe(200)
|
||||
expect((await app.request(`${base}/users/bob%40example.com/groups`, json('PUT', {groups: ['admins']}))).status).toBe(200)
|
||||
expect(await (await app.request(`${base}/groups`)).json()).toEqual([{name: 'admins', description: null, precedence: 0, createdAt: null}])
|
||||
expect((await app.request(`${base}/groups`, json('POST', {name: 'editors'}))).status).toBe(201)
|
||||
expect((await app.request(`${base}/groups/editors`, {method: 'DELETE'})).status).toBe(200)
|
||||
|
||||
const signIn = await app.request(`${base}/sign-in-test`, json('POST', {clientId: 'c', username: 'bob', password: 'x'}))
|
||||
expect(signIn.status).toBe(200)
|
||||
expect((await signIn.json()).outcome).toBe('rejected')
|
||||
|
||||
expect(calls).toEqual([
|
||||
['action', 'us-east-1_x', 'bob@example.com', {action: 'disable'}],
|
||||
['groups', 'us-east-1_x', 'bob@example.com', ['admins']],
|
||||
['deleteGroup', 'us-east-1_x', 'editors'],
|
||||
])
|
||||
})
|
||||
|
||||
test('clears the SES mailbox through the email adapter', async () => {
|
||||
let cleared = false
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
|
||||
@@ -392,6 +392,61 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/userpools/resources/:id/users/:username/actions', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).updateUserPoolUser(cloud, c.req.param('id'), c.req.param('username'), await c.req.json())
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/userpools/resources/:id/users/:username/groups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<{groups?: string[]}>()
|
||||
await svc(c).setUserPoolUserGroups(cloud, c.req.param('id'), c.req.param('username'), body.groups as string[])
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/userpools/resources/:id/groups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => c.json(await svc(c).listUserPoolGroups(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/userpools/resources/:id/groups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const group = await svc(c).createUserPoolGroup(cloud, c.req.param('id'), await c.req.json())
|
||||
return c.json(group, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/userpools/resources/:id/groups/:groupName', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteUserPoolGroup(cloud, c.req.param('id'), c.req.param('groupName'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/userpools/resources/:id/sign-in-test', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
@@ -1157,6 +1212,16 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const item = await svc(c).getFlatItem(target.cloud, target.service, c.req.param('id'), c.req.param('itemId'))
|
||||
return item ? c.json(item) : c.json({error: 'Item not found'}, 404)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
@@ -77,6 +77,11 @@ import type {
|
||||
StorageObjectDownload,
|
||||
StorageObjectList,
|
||||
UpdateResourceInput,
|
||||
CreateUserPoolGroupInput,
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
import type {
|
||||
ChildCollection,
|
||||
@@ -664,6 +669,12 @@ export class CloudProxyService {
|
||||
return this.requireItems(cloud, service).listItems(resourceId, page)
|
||||
}
|
||||
|
||||
async getFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, itemId: string): Promise<ChildItem | null> {
|
||||
const items = this.requireItems(cloud, service)
|
||||
if (!items.getItem) throw new NotSupportedError(`Reading one item is not supported for ${cloud}/${service}`)
|
||||
return items.getItem(resourceId, itemId)
|
||||
}
|
||||
|
||||
async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const items = this.requireItems(cloud, service)
|
||||
if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${cloud}/${service}`)
|
||||
@@ -784,6 +795,42 @@ export class CloudProxyService {
|
||||
return adapter.filterLogEvents(logGroupName, input, page)
|
||||
}
|
||||
|
||||
async updateUserPoolUser(cloud: CloudProvider, poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.updateUserPoolUser) throw new NotSupportedError(`User actions are not supported for ${cloud}/userpools`)
|
||||
await adapter.updateUserPoolUser(poolId, username, input)
|
||||
}
|
||||
|
||||
async setUserPoolUserGroups(cloud: CloudProvider, poolId: string, username: string, groups: string[]): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.setUserPoolUserGroups) throw new NotSupportedError(`User groups are not supported for ${cloud}/userpools`)
|
||||
await adapter.setUserPoolUserGroups(poolId, username, groups)
|
||||
}
|
||||
|
||||
async listUserPoolGroups(cloud: CloudProvider, poolId: string): Promise<UserPoolGroup[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.listUserPoolGroups) throw new NotSupportedError(`Groups are not supported for ${cloud}/userpools`)
|
||||
return adapter.listUserPoolGroups(poolId)
|
||||
}
|
||||
|
||||
async createUserPoolGroup(cloud: CloudProvider, poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.createUserPoolGroup) throw new NotSupportedError(`Group creation is not supported for ${cloud}/userpools`)
|
||||
return adapter.createUserPoolGroup(poolId, input)
|
||||
}
|
||||
|
||||
async deleteUserPoolGroup(cloud: CloudProvider, poolId: string, groupName: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.deleteUserPoolGroup) throw new NotSupportedError(`Group deletion is not supported for ${cloud}/userpools`)
|
||||
await adapter.deleteUserPoolGroup(poolId, groupName)
|
||||
}
|
||||
|
||||
async testUserPoolSignIn(cloud: CloudProvider, poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.testUserPoolSignIn) throw new NotSupportedError(`Test sign-in is not supported for ${cloud}/userpools`)
|
||||
return adapter.testUserPoolSignIn(poolId, input)
|
||||
}
|
||||
|
||||
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'k8s')
|
||||
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
||||
|
||||
@@ -130,6 +130,16 @@ export const apiEndpointKeys = {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
userpools: {
|
||||
userAction: "clouds.services.userpools.users.action",
|
||||
userGroups: "clouds.services.userpools.users.groups",
|
||||
groups: {
|
||||
list: "clouds.services.userpools.groups.list",
|
||||
create: "clouds.services.userpools.groups.create",
|
||||
delete: "clouds.services.userpools.groups.delete",
|
||||
},
|
||||
signInTest: "clouds.services.userpools.sign-in-test",
|
||||
},
|
||||
k8s: {
|
||||
nodegroups: {
|
||||
list: "clouds.services.k8s.nodegroups.list",
|
||||
@@ -154,6 +164,7 @@ export const apiEndpointKeys = {
|
||||
},
|
||||
},
|
||||
flatItems: {
|
||||
get: "clouds.services.flatItems.get",
|
||||
list: "clouds.services.flatItems.list",
|
||||
query: "clouds.services.flatItems.query",
|
||||
create: "clouds.services.flatItems.create",
|
||||
@@ -829,6 +840,48 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.userAction,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/actions",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.userGroups,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/groups",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.groups.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.groups.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/groups/:groupName",
|
||||
method: "DELETE",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.signInTest,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/sign-in-test",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||
{
|
||||
@@ -998,6 +1051,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.flatItems.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.flatItems.list,
|
||||
{
|
||||
|
||||
@@ -27,6 +27,11 @@ import type {
|
||||
DatabaseSnapshot,
|
||||
KubernetesFargateProfile,
|
||||
KubernetesNodegroup,
|
||||
CreateUserPoolGroupInput,
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
LogsFilterInput,
|
||||
@@ -1377,6 +1382,98 @@ export async function deleteFlatItem(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFlatItem(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
resourceId: string,
|
||||
itemId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChildItem> {
|
||||
const res = await apiClient.call<ChildItem>(
|
||||
apiEndpointKeys.clouds.flatItems.get,
|
||||
requestOptions(cloud, service, { signal }),
|
||||
{ cloud, service, id: resourceId, itemId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateUserPoolUser(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
username: string,
|
||||
input: UserPoolUserAction,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, UserPoolUserAction>(
|
||||
apiEndpointKeys.clouds.userpools.userAction,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId, username },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setUserPoolUserGroups(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
username: string,
|
||||
groups: string[],
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { groups: string[] }>(
|
||||
apiEndpointKeys.clouds.userpools.userGroups,
|
||||
requestOptions(cloud, "userpools", { body: { groups } }),
|
||||
{ cloud, id: poolId, username },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserPoolGroups(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UserPoolGroup[]> {
|
||||
const res = await apiClient.call<UserPoolGroup[]>(
|
||||
apiEndpointKeys.clouds.userpools.groups.list,
|
||||
requestOptions(cloud, "userpools", { signal }),
|
||||
{ cloud, id: poolId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createUserPoolGroup(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
input: CreateUserPoolGroupInput,
|
||||
): Promise<UserPoolGroup> {
|
||||
const res = await apiClient.call<UserPoolGroup, CreateUserPoolGroupInput>(
|
||||
apiEndpointKeys.clouds.userpools.groups.create,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteUserPoolGroup(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
groupName: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||
requestOptions(cloud, "userpools"),
|
||||
{ cloud, id: poolId, groupName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function testUserPoolSignIn(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
input: UserPoolSignInTestInput,
|
||||
): Promise<UserPoolSignInTestResult> {
|
||||
const res = await apiClient.call<UserPoolSignInTestResult, UserPoolSignInTestInput>(
|
||||
apiEndpointKeys.clouds.userpools.signInTest,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function putFlatItem(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {Trash2} from 'lucide-react'
|
||||
import {useMutation, useQueryClient, type UseQueryResult} from '@tanstack/react-query'
|
||||
import {useState} from 'react'
|
||||
import {createUserPoolGroup, deleteUserPoolGroup} from '@/api/cloudProxyClient'
|
||||
import type {UserPoolGroup} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
groupsQuery: UseQueryResult<UserPoolGroup[]>
|
||||
groupsKey: unknown[]
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
export function CognitoGroupsSection({poolId, groupsQuery, groupsKey, runtimeReachable}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [precedence, setPrecedence] = useState('')
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey: groupsKey})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createUserPoolGroup('aws', poolId, {
|
||||
name: name.trim(),
|
||||
...(description.trim() ? {description: description.trim()} : {}),
|
||||
...(precedence.trim() ? {precedence: Number(precedence)} : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setPrecedence('')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupName: string) => deleteUserPoolGroup('aws', poolId, groupName),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
|
||||
const groups = groupsQuery.data ?? []
|
||||
const pending = createMutation.isPending || deleteMutation.isPending
|
||||
const error = groupsQuery.error ?? createMutation.error ?? deleteMutation.error
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Groups</p><span className="muted">Group names appear in the <code>cognito:groups</code> token claim, so apps can check roles</span></div>
|
||||
</div>
|
||||
<form className="dynamic-form" aria-label="Create group" onSubmit={(event) => { event.preventDefault(); if (name.trim()) createMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Group name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="admins" disabled={pending} required/></label>
|
||||
<label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(event) => setDescription(event.target.value)} disabled={pending}/></label>
|
||||
<label className="dynamic-field"><span>Precedence</span><input className="input" type="number" min={0} step={1} value={precedence} onChange={(event) => setPrecedence(event.target.value)} disabled={pending}/><small className="muted">Lower wins when a user is in several groups.</small></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !name.trim() || pending}>{createMutation.isPending ? 'Creating' : 'Create group'}</button></div>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{groupsQuery.isSuccess && groups.length === 0 && <p className="muted padded">No groups yet.</p>}
|
||||
{groups.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Description</th><th>Precedence</th><th/></tr></thead><tbody>{groups.map((group) => (
|
||||
<tr key={group.name}>
|
||||
<td><code>{group.name}</code></td>
|
||||
<td>{group.description || '—'}</td>
|
||||
<td>{group.precedence ?? '—'}</td>
|
||||
<td><button className="icon-btn danger" type="button" title="Delete group" aria-label={`Delete group ${group.name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(group.name)}><Trash2 size={14}/></button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {testUserPoolSignIn} from '@/api/cloudProxyClient'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
appClients: Array<{clientId: string; clientName: string}>
|
||||
loginLabel: string
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const KEY_CLAIMS = ['sub', 'email', 'cognito:username', 'cognito:groups', 'client_id', 'aud', 'token_use', 'exp']
|
||||
|
||||
export function CognitoSignInTest({poolId, appClients, loginLabel, runtimeReachable}: Props) {
|
||||
const [clientId, setClientId] = useState(appClients[0]?.clientId ?? '')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!appClients.some((client) => client.clientId === clientId)) setClientId(appClients[0]?.clientId ?? '')
|
||||
}, [appClients, clientId])
|
||||
|
||||
const signIn = useMutation({
|
||||
mutationFn: () => testUserPoolSignIn('aws', poolId, {clientId, username: username.trim(), password}),
|
||||
})
|
||||
const result = signIn.data
|
||||
|
||||
if (appClients.length === 0) return null
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Test sign-in</p><span className="muted">Signs in the way an app does and shows what is inside the tokens</span></div>
|
||||
</div>
|
||||
<form className="dynamic-form" aria-label="Test sign-in" onSubmit={(event) => { event.preventDefault(); if (clientId && username.trim() && password) signIn.mutate() }}>
|
||||
<label className="dynamic-field"><span>App client</span><select className="input" value={clientId} onChange={(event) => setClientId(event.target.value)} disabled={signIn.isPending}>{appClients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}</select></label>
|
||||
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="off" disabled={signIn.isPending} required/></label>
|
||||
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="off" disabled={signIn.isPending} required/></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button" type="submit" disabled={!runtimeReachable || !clientId || !username.trim() || !password || signIn.isPending}>{signIn.isPending ? 'Signing in' : 'Try sign-in'}</button></div>
|
||||
</form>
|
||||
{signIn.error instanceof Error && <p className="error-text compact-text" role="alert">{signIn.error.message}</p>}
|
||||
{result?.outcome === 'rejected' && <p className="cognito-outcome cognito-outcome--rejected" role="status"><strong>Refused</strong> <code>{result.code}</code> {result.message}</p>}
|
||||
{result?.outcome === 'challenge' && <p className="cognito-outcome cognito-outcome--challenge" role="status"><strong>Cognito wants another step</strong> <code>{result.challengeName}</code> {challengeHint(result.challengeName)}</p>}
|
||||
{result?.outcome === 'signed-in' && <div className="cognito-outcome cognito-outcome--ok" role="status">
|
||||
<p><strong>Signed in.</strong> {result.expiresIn ? `The ID and access tokens are valid for ${Math.round(result.expiresIn / 60)} minutes.` : ''}</p>
|
||||
<div className="cognito-claims">
|
||||
<ClaimsTable title="ID token: who the user is" claims={result.idToken}/>
|
||||
<ClaimsTable title="Access token: what this app may do" claims={result.accessToken}/>
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
|
||||
function ClaimsTable({title, claims}: {title: string; claims: Record<string, unknown>}) {
|
||||
const keys = [...KEY_CLAIMS.filter((key) => key in claims), ...Object.keys(claims).filter((key) => !KEY_CLAIMS.includes(key)).sort()]
|
||||
return <div>
|
||||
<p className="eyebrow">{title}</p>
|
||||
<table className="table"><tbody>{keys.map((key) => (
|
||||
<tr key={key}><th scope="row"><code>{key}</code></th><td className="mono">{formatClaim(key, claims[key])}</td></tr>
|
||||
))}</tbody></table>
|
||||
</div>
|
||||
}
|
||||
|
||||
function formatClaim(key: string, value: unknown): string {
|
||||
if ((key === 'exp' || key === 'iat' || key === 'auth_time') && typeof value === 'number') return `${value} (${new Date(value * 1000).toLocaleString()})`
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
function challengeHint(challenge: string): string {
|
||||
if (challenge === 'NEW_PASSWORD_REQUIRED') return 'The password is temporary: the user must choose a new one before getting tokens.'
|
||||
if (challenge.includes('MFA') || challenge === 'SOFTWARE_TOKEN_MFA') return 'The user must enter a second-factor code.'
|
||||
return 'The app has to answer this challenge before Cognito issues tokens.'
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {getFlatItem, setUserPoolUserGroups, updateUserPoolUser} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {UserPoolGroup, UserPoolUserAction} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
username: string
|
||||
groups: UserPoolGroup[]
|
||||
usersKey: unknown[]
|
||||
runtimeReachable: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ACTION_DONE: Record<UserPoolUserAction['action'], string> = {
|
||||
enable: 'User enabled.',
|
||||
disable: 'User disabled. New sign-ins are refused; tokens already issued last until they expire.',
|
||||
confirm: 'User confirmed.',
|
||||
resetPassword: 'Password reset. The user must use a reset code to sign in again.',
|
||||
signOut: 'Signed out everywhere. Refresh tokens no longer work.',
|
||||
setPassword: 'Password set.',
|
||||
}
|
||||
|
||||
export function CognitoUserDetail({poolId, username, groups, usersKey, runtimeReachable, onClose}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const userKey = ['cognito-user', accountId, poolId, username]
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
|
||||
const [password, setPassword] = useState('')
|
||||
const [permanent, setPermanent] = useState(true)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: userKey,
|
||||
queryFn: ({signal}) => getFlatItem('aws', 'userpools', poolId, username, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const user = userQuery.data?.body
|
||||
const currentGroups = stringList(user?.groups)
|
||||
|
||||
// Reset the checkboxes only when the stored membership changes, not on every render.
|
||||
const storedGroups = currentGroups.join('\n')
|
||||
useEffect(() => {
|
||||
setSelectedGroups(storedGroups ? storedGroups.split('\n') : [])
|
||||
}, [storedGroups])
|
||||
|
||||
const refresh = () => Promise.all([queryClient.invalidateQueries({queryKey: userKey}), queryClient.invalidateQueries({queryKey: usersKey})])
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: (input: UserPoolUserAction) => updateUserPoolUser('aws', poolId, username, input),
|
||||
onSuccess: (_result, input) => {
|
||||
setNotice(ACTION_DONE[input.action])
|
||||
if (input.action === 'setPassword') setPassword('')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const groupsMutation = useMutation({
|
||||
mutationFn: () => setUserPoolUserGroups('aws', poolId, username, selectedGroups),
|
||||
onSuccess: () => {
|
||||
setNotice('Groups saved. They appear in tokens issued from the next sign-in.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
const pending = actionMutation.isPending || groupsMutation.isPending
|
||||
const error = userQuery.error ?? actionMutation.error ?? groupsMutation.error
|
||||
const run = (input: UserPoolUserAction) => { setNotice(''); actionMutation.mutate(input) }
|
||||
const groupsChanged = selectedGroups.slice().sort().join('\n') !== currentGroups.slice().sort().join('\n')
|
||||
const name = stringValue(user?.email) || username
|
||||
const attributes = Object.entries((user?.attributes ?? {}) as Record<string, string>)
|
||||
const disabled = !runtimeReachable || pending
|
||||
|
||||
return <section className="cognito-user" aria-label={`User ${name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">User</p><strong>{name}</strong></div>
|
||||
<button className="icon-btn" type="button" title="Close" aria-label="Close user details" onClick={onClose}><X size={14}/></button>
|
||||
</div>
|
||||
{userQuery.isLoading && <p className="muted padded">Loading user</p>}
|
||||
{user && <div className="cognito-user-grid">
|
||||
<dl className="cognito-facts">
|
||||
<dt>Status</dt><dd><code>{stringValue(user.status) || '—'}</code></dd>
|
||||
<dt>Enabled</dt><dd>{user.enabled === false ? 'No' : 'Yes'}</dd>
|
||||
<dt>Email verified</dt><dd>{user.emailVerified ? 'Yes' : 'No'}</dd>
|
||||
<dt>Created</dt><dd>{formatDateTime(userQuery.data?.timestamp ?? null) ?? '—'}</dd>
|
||||
<dt>Username</dt><dd className="mono">{username}</dd>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Actions</p>
|
||||
<div className="cognito-actions">
|
||||
{user.enabled === false
|
||||
? <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'enable'})}>Enable</button>
|
||||
: <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'disable'})}>Disable</button>}
|
||||
{user.status === 'UNCONFIRMED' && <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'confirm'})}>Confirm sign-up</button>}
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => run({action: 'signOut'})}>Sign out everywhere</button>
|
||||
<button className="button" type="button" disabled={disabled} title="Cognito sends the user a reset code" onClick={() => run({action: 'resetPassword'})}>Reset password</button>
|
||||
</div>
|
||||
<form className="cognito-set-password" onSubmit={(event) => { event.preventDefault(); if (password) run({action: 'setPassword', password, permanent}) }}>
|
||||
<label className="dynamic-field"><span>New password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={disabled}/></label>
|
||||
<label className="cognito-check"><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={disabled}/> User can sign in with it now</label>
|
||||
<button className="button" type="submit" disabled={disabled || !password}>Set password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Groups</p>
|
||||
{groups.length === 0 && <p className="muted">This pool has no groups. Create one in Groups above.</p>}
|
||||
{groups.map((group) => (
|
||||
<label key={group.name} className="cognito-check">
|
||||
<input type="checkbox" checked={selectedGroups.includes(group.name)} disabled={disabled} onChange={(event) => setSelectedGroups((current) => event.target.checked ? [...current, group.name] : current.filter((value) => value !== group.name))}/>
|
||||
<code>{group.name}</code>
|
||||
</label>
|
||||
))}
|
||||
{groups.length > 0 && <button className="button" type="button" disabled={disabled || !groupsChanged} onClick={() => { setNotice(''); groupsMutation.mutate() }}>{groupsMutation.isPending ? 'Saving' : 'Save groups'}</button>}
|
||||
</div>
|
||||
|
||||
{attributes.length > 0 && <div className="cognito-attributes">
|
||||
<p className="eyebrow">Attributes</p>
|
||||
<table className="table"><tbody>{attributes.sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => (
|
||||
<tr key={key}><th scope="row"><code>{key}</code></th><td className="mono">{value}</td></tr>
|
||||
))}</tbody></table>
|
||||
</div>}
|
||||
</div>}
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CognitoUsersPanel} from './CognitoUsersPanel'
|
||||
@@ -9,7 +9,14 @@ const api = vi.hoisted(() => ({
|
||||
deleteFlatItem: vi.fn(),
|
||||
getCloudResource: vi.fn(),
|
||||
listFlatItems: vi.fn(),
|
||||
listUserPoolGroups: vi.fn(),
|
||||
putFlatItem: vi.fn(),
|
||||
getFlatItem: vi.fn(),
|
||||
updateUserPoolUser: vi.fn(),
|
||||
setUserPoolUserGroups: vi.fn(),
|
||||
createUserPoolGroup: vi.fn(),
|
||||
deleteUserPoolGroup: vi.fn(),
|
||||
testUserPoolSignIn: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
@@ -39,6 +46,11 @@ beforeEach(() => {
|
||||
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
|
||||
api.putFlatItem.mockResolvedValue(alice)
|
||||
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||
api.listUserPoolGroups.mockResolvedValue([{name: 'admins', description: 'Full access', precedence: 0, createdAt: null}])
|
||||
api.getFlatItem.mockResolvedValue({...alice, body: {...alice.body, emailVerified: true, groups: [], attributes: {sub: alice.id, email: 'alice@example.com'}}})
|
||||
api.updateUserPoolUser.mockResolvedValue(undefined)
|
||||
api.setUserPoolUserGroups.mockResolvedValue(undefined)
|
||||
api.createUserPoolGroup.mockResolvedValue({name: 'editors', description: null, precedence: null, createdAt: null})
|
||||
})
|
||||
|
||||
test('shows the app client id and the pool users', async () => {
|
||||
@@ -58,9 +70,10 @@ test('creates an email user who can sign in immediately', async () => {
|
||||
renderPanel()
|
||||
|
||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||
await user.type(screen.getByLabelText('Email'), 'bob@example.com')
|
||||
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
|
||||
await user.click(screen.getByRole('button', {name: 'Create user'}))
|
||||
const form = screen.getByRole('form', {name: 'Create user'})
|
||||
await user.type(within(form).getByLabelText('Email'), 'bob@example.com')
|
||||
await user.type(within(form).getByLabelText('Password'), 'Passw0rd!')
|
||||
await user.click(within(form).getByRole('button', {name: 'Create user'}))
|
||||
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true})
|
||||
})
|
||||
@@ -70,10 +83,12 @@ test('creates a username user with a temporary password when asked', async () =>
|
||||
api.getCloudResource.mockResolvedValue(pool('username'))
|
||||
renderPanel()
|
||||
|
||||
await user.type(await screen.findByLabelText('Username'), 'bob')
|
||||
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
|
||||
await user.click(screen.getByLabelText(/User can sign in now/))
|
||||
await user.click(screen.getByRole('button', {name: 'Create user'}))
|
||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||
const form = screen.getByRole('form', {name: 'Create user'})
|
||||
await user.type(within(form).getByLabelText('Username'), 'bob')
|
||||
await user.type(within(form).getByLabelText('Password'), 'Passw0rd!')
|
||||
await user.click(within(form).getByLabelText(/User can sign in now/))
|
||||
await user.click(within(form).getByRole('button', {name: 'Create user'}))
|
||||
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||
})
|
||||
@@ -87,3 +102,52 @@ test('deletes a user by its stored username', async () => {
|
||||
|
||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id)
|
||||
})
|
||||
|
||||
test('manages a user: disables it and puts it in a group', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Manage user alice@example.com'}))
|
||||
await user.click(await screen.findByRole('button', {name: 'Disable'}))
|
||||
expect(api.updateUserPoolUser).toHaveBeenCalledWith('aws', summary.id, alice.id, {action: 'disable'})
|
||||
expect(await screen.findByText(/User disabled/)).toBeInTheDocument()
|
||||
|
||||
const groupBox = screen.getByRole('checkbox', {name: 'admins'})
|
||||
await user.click(groupBox)
|
||||
await user.click(screen.getByRole('button', {name: 'Save groups'}))
|
||||
expect(api.setUserPoolUserGroups).toHaveBeenCalledWith('aws', summary.id, alice.id, ['admins'])
|
||||
})
|
||||
|
||||
test('creates a group with its precedence', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
await user.type(await screen.findByLabelText('Group name'), 'editors')
|
||||
await user.type(screen.getByLabelText(/^Precedence/), '5')
|
||||
await user.click(screen.getByRole('button', {name: 'Create group'}))
|
||||
|
||||
expect(api.createUserPoolGroup).toHaveBeenCalledWith('aws', summary.id, {name: 'editors', precedence: 5})
|
||||
})
|
||||
|
||||
test('test sign-in shows the decoded claims, or why Cognito refused', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
api.testUserPoolSignIn
|
||||
.mockResolvedValueOnce({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
|
||||
.mockResolvedValueOnce({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: alice.id, email: 'alice@example.com', 'cognito:groups': ['admins']}, accessToken: {sub: alice.id, client_id: 'a8e5c7d38d3b4a1a97c9d822de'}})
|
||||
renderPanel()
|
||||
|
||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||
const form = screen.getByRole('form', {name: 'Test sign-in'})
|
||||
await user.type(within(form).getByLabelText('Email'), 'alice@example.com')
|
||||
await user.type(within(form).getByLabelText('Password'), 'wrong')
|
||||
await user.click(within(form).getByRole('button', {name: 'Try sign-in'}))
|
||||
expect(await screen.findByText('Incorrect username or password.')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(form).getByRole('button', {name: 'Try sign-in'}))
|
||||
expect(await screen.findByText(/valid for 60 minutes/)).toBeInTheDocument()
|
||||
expect(screen.getByText('cognito:groups', {selector: 'th code'})).toBeInTheDocument()
|
||||
expect(api.testUserPoolSignIn).toHaveBeenLastCalledWith('aws', summary.id, {clientId: 'a8e5c7d38d3b4a1a97c9d822de', username: 'alice@example.com', password: 'wrong'})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
|
||||
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
|
||||
import {CognitoUserDetail} from '@/components/CognitoUserDetail'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
@@ -20,7 +23,9 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
const [login, setLogin] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [permanent, setPermanent] = useState(true)
|
||||
const [openUser, setOpenUser] = useState<string | null>(null)
|
||||
const usersKey = ['cognito-users', accountId, resource?.id]
|
||||
const groupsKey = ['cognito-groups', accountId, resource?.id]
|
||||
|
||||
// The table row is a list summary; sign-in method and app clients come from the full pool.
|
||||
const poolQuery = useQuery({
|
||||
@@ -33,13 +38,25 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: groupsKey,
|
||||
queryFn: ({signal}) => listUserPoolGroups('aws', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLogin('')
|
||||
setPassword('')
|
||||
setPermanent(true)
|
||||
setOpenUser(null)
|
||||
}, [resource?.id])
|
||||
|
||||
// The pool list carries each pool's user count, which the delete confirmation reads.
|
||||
const refreshUsers = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: usersKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'userpools']}),
|
||||
])
|
||||
|
||||
const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username'
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
|
||||
@@ -50,12 +67,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
onSuccess: () => {
|
||||
setLogin('')
|
||||
setPassword('')
|
||||
return queryClient.invalidateQueries({queryKey: usersKey})
|
||||
return refreshUsers()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
|
||||
onSuccess: () => queryClient.invalidateQueries({queryKey: usersKey}),
|
||||
onSuccess: (_result, username) => {
|
||||
if (openUser === username) setOpenUser(null)
|
||||
return refreshUsers()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
@@ -77,11 +97,15 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr>
|
||||
))}</tbody></table></div>}
|
||||
|
||||
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
|
||||
|
||||
<CognitoGroupsSection poolId={resource.id} groupsQuery={groupsQuery} groupsKey={groupsKey} runtimeReachable={runtimeReachable}/>
|
||||
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Users</p><span className="muted">{users.length} users in {resource.name}</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
<div><p className="eyebrow">Users</p><span className="muted">{users.length} {users.length === 1 ? 'user' : 'users'} in {resource.name}</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch(), groupsQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
|
||||
<form className="dynamic-form" aria-label="Create user" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" type={signInWithEmail ? 'email' : 'text'} value={login} onChange={(event) => setLogin(event.target.value)} autoComplete="off" disabled={pending} required/></label>
|
||||
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={pending} required/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={pending}/> User can sign in now</span><small className="muted">When unchecked the password is temporary and must be changed at first sign-in.</small></label>
|
||||
@@ -97,9 +121,10 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
<td><code>{stringValue(user.body.status) || '—'}</code></td>
|
||||
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
||||
<td>{formatDateTime(user.timestamp) ?? '—'}</td>
|
||||
<td><button className="icon-btn danger" type="button" title="Delete user" aria-label={`Delete user ${name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(user.id)}><Trash2 size={14}/></button></td>
|
||||
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openUser === user.id} aria-label={`Manage user ${name}`} onClick={() => setOpenUser(openUser === user.id ? null : user.id)}>Manage</button><button className="icon-btn danger" type="button" title="Delete user" aria-label={`Delete user ${name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(user.id)}><Trash2 size={14}/></button></td>
|
||||
</tr>
|
||||
})}</tbody></table></div>}
|
||||
{openUser && <CognitoUserDetail key={openUser} poolId={resource.id} username={openUser} groups={groupsQuery.data ?? []} usersKey={usersKey} runtimeReachable={runtimeReachable} onClose={() => setOpenUser(null)}/>}
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
|
||||
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
||||
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
||||
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
||||
import { describeDeleteImpact } from "@/lib/deleteImpact";
|
||||
import { getPath } from "@/lib/resourcePath";
|
||||
import {
|
||||
capabilityEnabled,
|
||||
@@ -574,12 +575,14 @@ export function DynamicResourceView({
|
||||
const resources = resourcesQuery.data ?? [];
|
||||
const canCreate = schema.actions.includes("create");
|
||||
const canDelete = schema.actions.includes("delete");
|
||||
const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems);
|
||||
const isAllSelected =
|
||||
resources.length > 0 &&
|
||||
resources.every((r) => selectedItems.some((s) => s.id === r.id));
|
||||
// Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot.
|
||||
const activeSelected =
|
||||
selected?.cloud === cloud && selected.service === service
|
||||
? selected
|
||||
? resources.find((resource) => resource.id === selected.id) ?? selected
|
||||
: undefined;
|
||||
const runtimeReachable = cloudStatus?.runtime === "reachable";
|
||||
const resourceCapabilityInputs =
|
||||
@@ -744,6 +747,9 @@ export function DynamicResourceView({
|
||||
{canDelete && (
|
||||
deleteConfirm ? (
|
||||
<>
|
||||
{deleteImpactText && (
|
||||
<span className="delete-impact" role="status">{deleteImpactText}</span>
|
||||
)}
|
||||
<button
|
||||
className="button danger"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {ResourceTable} from './ResourceTable'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
|
||||
const schema: ServiceSchema = {
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
displayName: 'Cognito',
|
||||
fields: [],
|
||||
actions: ['list', 'delete'],
|
||||
filters: [],
|
||||
columns: [{name: 'name', label: 'Name'}],
|
||||
deleteImpact: [
|
||||
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
|
||||
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||
],
|
||||
}
|
||||
|
||||
const pool: CloudResource = {
|
||||
id: 'us-east-1_abc', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||
region: 'us-east-1', createdAt: null, metadata: {userCount: 2, appClients: [{clientId: 'c1'}]},
|
||||
}
|
||||
|
||||
test('the row delete confirmation says what else is deleted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDelete = vi.fn()
|
||||
render(<ResourceTable schema={schema} resources={[pool]} onSelect={vi.fn()} onDelete={onDelete}/>)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Delete orders-users'}))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('This also deletes 2 users and 1 app client.')
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Confirm'}))
|
||||
expect(onDelete).toHaveBeenCalledWith(pool)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Pencil, Trash2} from 'lucide-react';
|
||||
import {describeDeleteImpact} from '@/lib/deleteImpact';
|
||||
import type {CloudResource} from '@/types/resource';
|
||||
import type {ServiceSchema} from '@/types/schema';
|
||||
import {getPath} from '@/lib/resourcePath';
|
||||
@@ -140,6 +141,7 @@ export function ResourceTable({
|
||||
className="table-confirm-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DeleteImpact schema={schema} resource={resource} />
|
||||
<button
|
||||
className="button danger compact"
|
||||
type="button"
|
||||
@@ -207,3 +209,8 @@ function CopyableColumnValue({resource, column}: {resource: CloudResource; colum
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteImpact({schema, resource}: {schema: ServiceSchema; resource: CloudResource}) {
|
||||
const text = describeDeleteImpact(schema.deleteImpact, [resource]);
|
||||
return text ? <span className="delete-impact" role="status">{text}</span> : null;
|
||||
}
|
||||
|
||||
@@ -779,6 +779,47 @@ pre,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.topbar .search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar .search input {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.topbar .kbd,
|
||||
.topbar .account-meta,
|
||||
.topbar .connection-target {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar .connection-state {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar .connection {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.topbar .account-trigger {
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-status:empty + .account-switcher {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -4266,6 +4307,110 @@ pre,
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cognito-row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cognito-outcome {
|
||||
margin: 0 16px 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent-tint-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cognito-outcome p { margin: 0 0 12px; }
|
||||
.cognito-outcome--ok { border-left-color: var(--status-ok); }
|
||||
.cognito-outcome--challenge { border-left-color: var(--status-warn); }
|
||||
.cognito-outcome--rejected { border-left-color: var(--status-error); }
|
||||
|
||||
.cognito-claims {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cognito-claims .table th,
|
||||
.cognito-attributes .table th {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cognito-user {
|
||||
font-size: 13px;
|
||||
margin: 0 16px 16px;
|
||||
border: 1px solid var(--accent-tint-border);
|
||||
border-radius: 8px;
|
||||
background: var(--elevated);
|
||||
}
|
||||
|
||||
.cognito-user-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.cognito-attributes { grid-column: 1 / -1; overflow-x: auto; }
|
||||
|
||||
.cognito-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cognito-facts dt { color: var(--text-muted); }
|
||||
.cognito-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.cognito-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cognito-set-password {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.cognito-set-password .dynamic-field { width: 100%; }
|
||||
|
||||
.cognito-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.delete-impact {
|
||||
color: var(--status-warn);
|
||||
font-size: 13px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.table-confirm-actions:has(.delete-impact) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-confirm-actions .delete-impact {
|
||||
flex-basis: 100%;
|
||||
max-width: 220px;
|
||||
margin-left: auto;
|
||||
white-space: normal;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {describeDeleteImpact} from './deleteImpact'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const impact = [
|
||||
{path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'},
|
||||
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||
]
|
||||
|
||||
function pool(users: unknown, clients: unknown[]): CloudResource {
|
||||
return {id: 'p', name: 'p', cloud: 'aws', service: 'userpools', type: 'user-pool', region: null, createdAt: null, metadata: {estimatedNumberOfUsers: users, appClients: clients}}
|
||||
}
|
||||
|
||||
test('sums numbers and list lengths across the selection', () => {
|
||||
expect(describeDeleteImpact(impact, [pool(2, [{}]), pool(1, [])])).toBe('This also deletes 3 users and 1 app client.')
|
||||
})
|
||||
|
||||
test('leaves out zero and unreadable counts', () => {
|
||||
expect(describeDeleteImpact(impact, [pool(0, [{}, {}])])).toBe('This also deletes 2 app clients.')
|
||||
expect(describeDeleteImpact(impact, [pool(null, [])])).toBeNull()
|
||||
expect(describeDeleteImpact(undefined, [pool(5, [])])).toBeNull()
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
/** Shared with the login page (public/login.js) and index.html. */
|
||||
const THEME_KEY = 'floci-theme'
|
||||
|
||||
function getStored(): Theme {
|
||||
const v = localStorage.getItem('floci-theme')
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY)
|
||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — fall back to the default theme.
|
||||
return 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
@@ -12,7 +20,11 @@ export function useTheme() {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('floci-theme', theme)
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
} catch {
|
||||
// Persisting the choice is optional when storage is unavailable.
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return {theme, setTheme}
|
||||
|
||||
@@ -49,7 +49,9 @@ test('summarises the runtime and lists failing console services first', async ()
|
||||
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
|
||||
const consoleServices = screen.getByRole('region', {name: 'Console services'})
|
||||
const rows = within(consoleServices).getAllByRole('row')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[1]).toHaveTextContent('messaging')
|
||||
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
|
||||
|
||||
@@ -59,6 +61,25 @@ test('summarises the runtime and lists failing console services first', async ()
|
||||
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('keeps responding console services behind a toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudDiagnostics.mockResolvedValue({
|
||||
...healthy,
|
||||
consoleServices: healthy.consoleServices.map((service) => ({...service, runtime: 'reachable', error: null, errorCode: null})),
|
||||
} satisfies RuntimeDiagnostics)
|
||||
renderPage()
|
||||
const consoleServices = await screen.findByRole('region', {name: 'Console services'})
|
||||
|
||||
expect(within(consoleServices).getByText('All 2 responding')).toBeInTheDocument()
|
||||
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(within(consoleServices).getByRole('button', {name: 'Show 2 responding'}))
|
||||
expect(within(consoleServices).getAllByRole('row')).toHaveLength(3)
|
||||
|
||||
await user.click(within(consoleServices).getByRole('button', {name: 'Hide responding'}))
|
||||
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('filters emulated services by console coverage and state', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||
|
||||
@@ -88,17 +88,25 @@ function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
|
||||
}
|
||||
|
||||
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||
const [showResponding, setShowResponding] = useState(false)
|
||||
const failing = services.filter((service) => service.runtime !== 'reachable')
|
||||
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
|
||||
const responding = services.filter((service) => service.runtime === 'reachable')
|
||||
const ordered = showResponding ? [...failing, ...responding] : failing
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-console-services">Console services</h2>
|
||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
|
||||
<div className="diagnostics-filters">
|
||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `All ${services.length} responding`}</span>
|
||||
{responding.length > 0 && (
|
||||
<button className="button compact" type="button" aria-expanded={showResponding} onClick={() => setShowResponding((value) => !value)}>
|
||||
{showResponding ? 'Hide responding' : `Show ${responding.length} responding`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{services.length === 0
|
||||
? <p className="muted padded">No console services are registered for this cloud.</p>
|
||||
: <table className="table">
|
||||
{services.length === 0 && <p className="muted padded">No console services are registered for this cloud.</p>}
|
||||
{ordered.length > 0 && <table className="table">
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
||||
<tbody>{ordered.map((service) => (
|
||||
<tr key={service.service}>
|
||||
|
||||
@@ -274,3 +274,35 @@ export interface AppConfigDeployment {
|
||||
completedAt: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export type UserPoolUserAction =
|
||||
| {action: 'enable'}
|
||||
| {action: 'disable'}
|
||||
| {action: 'confirm'}
|
||||
| {action: 'resetPassword'}
|
||||
| {action: 'signOut'}
|
||||
| {action: 'setPassword'; password: string; permanent?: boolean}
|
||||
|
||||
export interface UserPoolGroup {
|
||||
name: string
|
||||
description: string | null
|
||||
precedence: number | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserPoolGroupInput {
|
||||
name: string
|
||||
description?: string
|
||||
precedence?: number
|
||||
}
|
||||
|
||||
export interface UserPoolSignInTestInput {
|
||||
clientId: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UserPoolSignInTestResult =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
| {outcome: 'rejected'; code: string; message: string}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type KubernetesActionName =
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -86,8 +87,11 @@ export interface ServiceSchema {
|
||||
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
|
||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
updateFields?: FieldSchema[]
|
||||
/** Counts read from the resource that the delete confirmation names. */
|
||||
deleteImpact?: Array<{path: string; singular: string; plural: string}>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user