├── .gitignore ├── Amazon-Connect-Global-Resiliency-Dashboard-User-Guide-v1.pdf ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cdk-stacks ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── cdk-stacks.ts ├── cdk.json ├── config.params.json ├── configure.js ├── jest.config.js ├── lambda-layer │ └── nodejs │ │ ├── node_modules │ │ └── querystring │ │ │ ├── .History.md.un~ │ │ │ ├── .Readme.md.un~ │ │ │ ├── .package.json.un~ │ │ │ └── test │ │ │ └── .index.js.un~ │ │ ├── package-lock.json │ │ └── package.json ├── lambdas │ ├── .eslintrc.json │ ├── constants │ │ ├── PairedRegions.js │ │ └── Tags.js │ ├── custom-resources │ │ └── frontend-config │ │ │ └── index.py │ ├── handlers │ │ └── ConnectAPI │ │ │ ├── connectCreateTrafficDistributionGroup.js │ │ │ ├── connectDeleteTrafficDistributionGroup.js │ │ │ ├── connectListInstances.js │ │ │ ├── connectListPhoneNumbers.js │ │ │ ├── connectListTrafficDistributionGroups.js │ │ │ ├── connectReplicateInstance.js │ │ │ ├── connectShowInstance.js │ │ │ ├── connectShowTrafficDistributionGroup.js │ │ │ ├── connectUpdatePhoneNumbers.js │ │ │ └── connectUpdateTrafficDistribution.js │ ├── lib │ │ ├── AuthUtility.js │ │ ├── CommonUtility.js │ │ ├── Error.js │ │ └── LambdaUtility.js │ ├── package-lock.json │ ├── package.json │ └── services │ │ └── ConnectService.js ├── lib │ ├── api │ │ └── connectAPI-stack.ts │ ├── cdk-backend-stack.ts │ ├── cdk-frontend-stack.ts │ ├── frontend │ │ ├── frontend-config-stack.ts │ │ └── frontend-s3-deployment-stack.ts │ └── infrastructure │ │ ├── cognito-stack.ts │ │ └── ssm-params-util.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── documentation ├── diagrams │ └── GlobalResiliency-Architecture.png └── images │ └── instance-overview-screen.png └── webapp ├── .eslintrc.js ├── .eslintrc.json ├── .gitignore ├── package-lock.json ├── package.json ├── src ├── App.css ├── App.js ├── apis │ └── connectAPI.js ├── components │ ├── ConfirmationModal.js │ ├── Notifications.js │ └── SignoutButton.js ├── constants │ └── routes.js ├── hooks │ ├── useNonInitialEffect.js │ └── useToggle.js ├── images │ ├── dashboard-logo-dark.png │ ├── dashboard-logo-light.png │ └── favicon.ico ├── index.css ├── index.html ├── index.js ├── providers │ ├── AppConfigProvider.js │ └── AppStateProvider.js └── views │ ├── Instance.js │ ├── InstanceList.js │ ├── ManageNumbers.js │ └── TrafficDistributionGroup.js ├── webpack.config.common.js ├── webpack.config.dev.js └── webpack.config.prod.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | #ignore cdk-exports.json, as it's generated by CDK 9 | cdk-exports.json 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | .eslintcache 23 | /.vscode 24 | .vscode 25 | /.idea/ 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # General 32 | .DS_Store 33 | .AppleDouble 34 | .LSOverride 35 | 36 | # Icon must end with two \r 37 | Icon 38 | 39 | 40 | # Thumbnails 41 | ._* 42 | 43 | # Files that might appear in the root of a volume 44 | .DocumentRevisions-V100 45 | .fseventsd 46 | .Spotlight-V100 47 | .TemporaryItems 48 | .Trashes 49 | .VolumeIcon.icns 50 | .com.apple.timemachine.donotpresent 51 | 52 | # Directories potentially created on remote AFP share 53 | .AppleDB 54 | .AppleDesktop 55 | Network Trash Folder 56 | Temporary Items 57 | .apdisk 58 | 59 | -------------------------------------------------------------------------------- /Amazon-Connect-Global-Resiliency-Dashboard-User-Guide-v1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/Amazon-Connect-Global-Resiliency-Dashboard-User-Guide-v1.pdf -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.8] - 2024-02-20 8 | - Update Lambda function to Node 20 9 | - Dependency security updates 10 | 11 | ## [1.0.7] - 2023-05-01 12 | - Dependency security updates 13 | 14 | ## [1.0.6] - 2023-04-18 15 | - Dependency security updates 16 | 17 | ## [1.0.5] - 2022-12-13 18 | - Dependency security updates 19 | 20 | ## [1.0.4] - 2022-11-25 21 | - Updated README with link to failover runbook for Amazon Connect Global Resiliency 22 | 23 | ## [1.0.3] - 2022-11-18 24 | - TDG and Manage Phone Numbers will now reload objects when bookmarking or refreshing the page 25 | - Hyperlinks to directly manage Instances and Traffic Distribution Groups 26 | - Updated Readme and User guide with bookmark guidance 27 | - Dependency security updates 28 | - Small cleanup changes 29 | 30 | ## [1.0.2] - 2022-11-16 31 | - README instructions on how to run webapp locally 32 | - Tagging added 33 | - Small cleanup changes 34 | 35 | ## [1.0.1] - 2022-11-15 36 | - README updates with more Windows specific changes 37 | - Architecture Diagram Updates 38 | 39 | ## [1.0.0] - 2022-11-11 40 | Initial Release -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon Connect Global Resiliency dashboard starter project 2 | 3 | >**BE AWARE:** This code base is an [Open Source](LICENSE) starter project designed to provide a demonstration and a base to start from for specific use cases. 4 | It should not be considered fully Production-ready. 5 | If you plan to deploy and use this in a Production environment please review the [Using this in Production](#using-this-in-production) section at the end for some additional guidance. 6 | 7 | ## Use-case scenario 8 | 9 | [Amazon Connect](https://docs.aws.amazon.com/connect/latest/adminguide/what-is-amazon-connect.html) is an omnichannel cloud contact center that allows you to set up a contact center in just a few steps. 10 | 11 | Using [Amazon Connect Global Resiliency](https://docs.aws.amazon.com/connect/latest/adminguide/setup-connect-global-resiliency.html) you can link an Amazon Connect instance to one in another AWS Region and provision and manage phone numbers that are global and accessible in both Regions. 12 | You can distribute traffic between instances in 10% increments allowing failover between regions. 13 | 14 | Currently you can interact with these features through the AWS CLI or APIs. 15 | This is a starter project that demonstrates and provides a starting point for how you can build a dashboard that can interact with Amazon Connect's Global Resiliency features through a UI instead. 16 | 17 | **Before using the Amazon Connect Global Resiliency features through this dashboard you should review the [requirements](https://docs.aws.amazon.com/connect/latest/adminguide/get-started-connect-global-resiliency.html) and pricing for Amazon Connect. 18 | You may need to engage with your Account team in order to enable these features.** 19 | 20 | 21 | ## The dashboard 22 | The dashboard provides screens that let you navigate through and replicate your instances, view and create traffic distribution groups, and view and associate phone numbers to them. It also lets you redistribute the traffic between instances. 23 | 24 | ![instance overview screen](documentation/images/instance-overview-screen.png) 25 | 26 | > **Tip:** You can create bookmarks to the manage traffic distribution group screens. This allows you to quickly navigate to a specific traffic distribution group when needed. 27 | 28 | 29 | ### Key limitations 30 | - Once an instance has been replicated it will be empty. You will need to use the AWS Console (and Amazon Connect console) to configure it.. 31 | - You cannot claim new numbers from the dashboard, only move them into and out of your traffic distribution group. 32 | - You cannot associate numbers to queues from the dashboard, please ensure any phone numbers moved to your traffic distribution groups are associated to queues on both instances through the standard Amazon Connect dashboard. 33 | - Certain actions can only be taken on the source Connect instance and not on the replica created from it, for instance adding TDGs and assigning numbers to a TDG. This is a limitation of the APIs themselves. 34 | Please ensure that you have all your configuration set up prior to needing failover. 35 | Check the [docs](https://docs.aws.amazon.com/connect/latest/adminguide/setup-connect-global-resiliency.html) for more details on specific operations. 36 | (NOTE: you **can** distribute traffic between instances from either instance). 37 | 38 | > This application MUST be deployed into **both regions** where your linked Amazon Connect instances are. This is necessary for a failover situation where you might not be able to access one of the environments. 39 | 40 | 41 | 42 | ---- 43 | 44 | ## Solution components 45 | 46 | On a high-level, the solution consists of the following components, each contained in these folders: 47 | 48 | * **website** - The dashboard front-end written in React 49 | * **cdk-stacks** - AWS CDK stacks: 50 | - `cdk-backend-stack` with all the backend resources needed for the solution (AWS Lambda, Amazon API Gateway, Amazon Cognito, etc) 51 | - `cdk-front-end-stack` with front-end resources for hosting the webapp (Amazon S3, Amazon CloudFront distribution) 52 | 53 | 54 | ### Solution architecture: 55 | 56 | ![architecture](documentation/diagrams/GlobalResiliency-Architecture.png) 57 | 58 | 59 | ## Solution prerequisites 60 | * AWS Account 61 | * [AWS IAM user](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html) with Administrator permissions 62 | * Amazon Connect instance (SSO/SAML enabled) 63 | * [Node](https://nodejs.org/) (v16) and [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (v8.5) installed and configured on your computer 64 | * [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) (v2) installed and configured on your computer 65 | * [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (v2) installed and configured on your computer 66 | 67 | ## Solution setup 68 | 69 | The below instructions show how to deploy the solution using AWS CDK CLI. If you are using a Windows device please use the [Git BASH](https://gitforwindows.org/) terminal 70 | and use alternative commands where highlighted. 71 | 72 | These instructions assume you have completed all the prerequisites, and you have an existing Amazon Connect instance with SSO/SAML enabled. 73 | 74 | 1. Clone the solution to your computer (using `git clone`) 75 | 76 | 2. Check AWS CLI 77 | - AWS CDK will use AWS CLI local credentials and region 78 | - check your AWS CLI configuration by running an AWS CLI command (e.g. `aws s3 ls`) 79 | - you can also use profiles (i.e. `export AWS_PROFILE=<>`) 80 | - you can confirm the configured region with 81 | `aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]'` 82 | 83 | 84 | 3. Install NPM packages 85 | - Open your Terminal and navigate to `amazon-connect-global-resiliency/cdk-stacks` 86 | - Run `npm run install:all` 87 | - This script goes through all packages of the solution and installs necessary modules (webapp, cdk-stacks, lambdas, lambda-layers) 88 | 89 | 90 | 4. Configure CDK stacks 91 | - In your terminal, navigate to `amazon-connect-global-resiliency/cdk-stacks` 92 | - To see the full instructions for the configuration script, run 93 | `node configure.js -h` 94 | - For the purpose of this guide, start the configuration script in interactive mode which will guide you through each input one at a time. 95 | (Note, it is possible to configure it via single command, by directly providing parameters, as described in the script help instructions) 96 | 97 | `node configure.js -i` 98 | - When prompted, provide the following parameters: 99 | - `cognito-domain-prefix`: Amazon Cognito hosted UI domain prefix, where users will be redirected during the login process. 100 | The domain prefix has to be unique. It can include only lowercase letters, numbers, and hyphens. Do not use a hyphen for the first or last character. Use periods to separate subdomain names. You can't use the text aws, amazon, or cognito in the domain prefix. 101 | - `webapp-api-allowed-origins`: Allowed Origins for web app APIs, please keep * at this point, we will come back to it once our front-end is deployed. 102 | - `cognito-saml-enabled`: as a starting point, set this parameter to `false`. _(If setting to `true`, please review the help instructions for information on additional parameters you will need to fill out: `node configure.js -h`)_ 103 | 104 | 105 | 5. Deploy CDK stacks 106 | - In your terminal, navigate to navigate to `amazon-connect-global-resiliency/cdk-stacks` 107 | - Run the script: `npm run build:frontend` (remember to complete this step whenever you want to deploy new front end changes) 108 | - **On Windows devices use `npm run build:frontend:gitbash`**. 109 | - This script builds frontend applications (webapp) 110 | - If you have started with a new environment, please bootstrap CDK: `cdk bootstrap` 111 | - Run the script: `npm run cdk:deploy` 112 | - **On Windows devices use `npm run cdk:deploy:gitbash`**. 113 | - This script deploys CDK stacks 114 | - Wait for all resources to be provisioned before continuing to the next step 115 | - AWS CDK output will be provided in your Terminal. You should see the Amazon Cognito User Pool Id as `userPoolId` from your Backend stack, 116 | and Amazon CloudFront Distribution URL as `webAppURL` from your Frontend stack. 117 | **Save these values as you will be using them in the next few steps.** 118 | 119 | 120 | 6. Create Cognito User 121 | - To create an Amazon Cognito user, you'll need Cognito User Pool Id (created in step 5 - check for the AWS CDK Output, or check it in your AWS Console > Cognito User Pools) 122 | - Create an Amazon Cognito user either user directly in the [Cognito Console](https://docs.aws.amazon.com/cognito/latest/developerguide/how-to-create-user-accounts.html#creating-a-new-user-using-the-users-tab) or by executing: 123 | `aws cognito-idp admin-create-user --region <> --user-pool-id <> --username <> --user-attributes "Name=name,Value=<>" --desired-delivery-mediums EMAIL` 124 | - You will receive an email, with a temporary password, which you will need in step 7 125 | **You can repeat this step for each person you want to give access to either now or at a later date. Remember to create each user in both regions** 126 | 127 | 128 | 7. Configure API Allowed Origins (optional) 129 | - Cross-origin resource sharing (CORS) is a browser security feature that restricts cross-origin HTTP requests that are initiated from scripts running in the browser. At this point, we can restrict our APIs to be accessible only from our Amazon CloudFront Distribution domain (origin). 130 | - In your terminal, navigate to `amazon-connect-global-resiliency/cdk-stacks` 131 | - Start the configuration script in interactive mode, with additional `-l` (load) parameter 132 | `node configure.js -i -l` 133 | - The script loads all the existing parameters, and prompts for new parameters to be provided 134 | - Accept all the existing parameters, but provide a new value for: 135 | - webapp-api-allowed-origins: Domain of your agent application, in this case Amazon CloudFront Distribution URL. For instance: `https://aaaabbbbcccc.cloudfront.net` 136 | - The script stores the deployment parameters to AWS System Manager Parameter Store 137 | - While in `amazon-connect-global-resiliency/cdk-stacks`, run the deploy script: `npm run cdk:deploy` 138 | - **On Windows devices use `npm run cdk:deploy:gitbash`**. 139 | - Wait for the CDK stacks to be updated 140 | 141 | 142 | 8. Test the solution 143 | - Open your browser and navigate to Amazon CloudFront Distribution URL (Output to the console and also available in the Outputs of the Frontend Cloudformation Stack) 144 | - On the Login screen, provide your email address and temporary password you received via email 145 | - If logging in the first time you will be prompted to reset your password. 146 | - You should now see a list of your Amazon Connect instances and be able to select one and interact with the Global Resiliency features. You can learn more about the screens from the [User Guide](./Amazon-Connect-Global-Resiliency-Dashboard-User-Guide-v1.pdf). 147 | 148 | 149 | 9. Deploy your solution into the second region 150 | - Switch the region either in your profile or alternatively in your CLI using `export AWS_DEFAULT_REGION=<>` 151 | - You can confirm the configured region with 152 | `aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]'` 153 | - Rerun steps 4-8 to deploy into the second region 154 | 155 | 156 | 157 | ## Clean up 158 | 159 | To remove the solution from your account, please follow these steps in each region you have deployed into: 160 | 161 | 1. Remove CDK Stacks 162 | - Run `cdk destroy --all` 163 | 164 | 1. Remove deployment parameters from AWS System Manager Parameter Store 165 | - Run `node configure.js -d` 166 | 167 | 168 | --- 169 | 170 | ## Using this in Production 171 | 172 | It is critical that before you use any of this code in Production that you work with your own internal Security and Governance teams to get the appropriate Code and AppSec reviews for your organization. 173 | 174 | Although the code has been written with best practices in mind, your own company may require different ones, or have additional rules and restrictions. 175 | 176 | You take full ownership and responsibility for the code running in your environment, and are free to make whatever changes you need to. 177 | 178 | >It is critical that you test this solution once deployed AND incorporate frequent failover tests (monthly or at least quarterly) as part of your organization's larger Disaster Recovery Drills. You can find more information on general best practices for a failover runbook for Amazon Connect Global Resiliency [here](https://catalog.workshops.aws/amazon-connect-global-resiliency/en-US/connectbestpractices#failover-runbook). 179 | 180 | **Some of the things you will want to consider** 181 | - The starter project has extensive logging to CloudWatch, but does not have any monitoring or tracing included, you may want to look at using tools like Cloudwatch Alarms and X-ray. 182 | - The starter project uses Cognito user pools, but you may want to consider using Cognito identity pools (federated identities) to connect it to your current identity providers. 183 | - If you decide to use Cognito, you will want to check the password policy matches your expectations and you may want to enable MFA. 184 | - The starter project only provides access through Cloudfront, you will likely want to integrate it with a firewall like AWS WAF, and should verify if any restrictions should be added to your Cloudfront distribution (e.g. geo restrictions). 185 | - The starter project tags all resources with the tags listed in `cdk-stacks/config.params.json` and anything created through the dashboard has the tags in `cdk-stacks/lambdas/constants/Tags.js` added. You may wish to replace them with your own company requirements, or tag at a more granular level. 186 | 187 | 188 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /cdk-stacks/.gitignore: -------------------------------------------------------------------------------- 1 | # *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules/ 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | 10 | # Parcel default cache directory 11 | .parcel-cache 12 | 13 | # CDK js 14 | cdk-backend*.js 15 | 16 | # CDK config, produced by configure.sh 17 | config.cache.json 18 | 19 | # Local template file 20 | template.yaml 21 | 22 | # CDK context - auto-generated 23 | cdk.context.json 24 | 25 | #build folder 26 | build -------------------------------------------------------------------------------- /cdk-stacks/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /cdk-stacks/README.md: -------------------------------------------------------------------------------- 1 | # AWS CDK stacks with all the backend and frontend resources 2 | 3 | ## Useful commands 4 | 5 | * `npm run install:all` install all necessary modules 6 | * `npm run build` compile typescript to js 7 | * `npm run configure` start the configuration script 8 | * `npm run sync-config` download frontend-config.js for local frontend testing 9 | * `npm run build:frontend` build frontend applications 10 | * `npm run cdk:deploy` deploy backend and frontend stacks to your default AWS account/region 11 | * `npm run cdk:deploy:gitbash` deploy backend and frontend stacks to your default AWS account/region (WINDOWS) 12 | * `npm run build:deploy:all` build frontend applications and deploy stacks to your default AWS account/region 13 | * `npm run build:deploy:all:gitbash` build frontend applications and deploy stacks to your default AWS account/region (WINDOWS) 14 | 15 | ## What's different about the gitbash (windows) specific commands 16 | Building on Windows requires a few small changes that have been bundled into different gitbash specific scripts: 17 | * Use of `set` to configure the `NODE_ENV` environment variable - [More Information](https://stackoverflow.com/a/9250168) 18 | * All `cdk` commands are prefixed with `winpty` - [More Information](https://github.com/git-for-windows/git/wiki/FAQ#some-native-console-programs-dont-work-when-run-from-git-bash-how-to-fix-it) 19 | 20 | ## Running the front end locally against your deployed services 21 | If you want to run locally against your deployed API Gateway and AWS Lambda code you will need to complete the following steps: 22 | - Ensure you have fully deployed your back-end code 23 | - Ensure that you have set the region in your config to the region of the back-end you want to test 24 | - Run `aws configure` to check or change the region 25 | - Run `npm run sync-config`. This will sync down your SSM params into a file called `frontend-config.js` 26 | - Ensure that localhost is included in your Allowed Origins for your API Gateway 27 | (see Step 7 in the main [README](../README.md), you can reference your cloudfront url as well as localhost by separating them with a comma for the `webapp-api-allowed-origins` param) 28 | - In your command line navigate to the `webapp` folder and run `npm run start` 29 | - This will launch the front end at https://localhost:3001/ 30 | 31 | IMPORTANT: 32 | - **DO NOT point localhost at your Production environment**. The above steps are to allow local development against a non-Prod environment. 33 | - **DO NOT put `frontend-config.js` into your source control.** It is listed in the gitignore file, so will be ignored by default in the standard project configuration. 34 | 35 | 36 | -------------------------------------------------------------------------------- /cdk-stacks/bin/cdk-stacks.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | // SPDX-License-Identifier: MIT-0 5 | 6 | import 'source-map-support/register'; 7 | import {App, Tags} from 'aws-cdk-lib' 8 | import { CdkBackendStack } from '../lib/cdk-backend-stack'; 9 | import { CdkFrontendStack } from '../lib/cdk-frontend-stack'; 10 | import { AwsSolutionsChecks } from 'cdk-nag' 11 | import { Aspects } from 'aws-cdk-lib'; 12 | 13 | const configParams = require('../config.params.json'); 14 | 15 | const app = new App(); 16 | Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })) //Comment this line to bypass cdk-nag 17 | 18 | const tags = configParams['tags'] 19 | Object.entries(tags).forEach(([key, value]) => { 20 | if (typeof value === "string") { 21 | Tags.of(app).add(key, value); 22 | } 23 | }) 24 | 25 | console.log("Running in stack mode..."); 26 | const cdkBackendStack = new CdkBackendStack(app, configParams['CdkBackendStack'], { 27 | env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION } 28 | }); 29 | 30 | const cdkFrontendStack = new CdkFrontendStack(app, configParams['CdkFrontendStack'], { 31 | env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 32 | webAppBucket: cdkBackendStack.webAppBucket, 33 | accessLogsBucket: cdkBackendStack.accessLogsBucket, 34 | webAppCloudFrontOAI: cdkBackendStack.webAppCloudFrontOAI 35 | }); 36 | cdkFrontendStack.addDependency(cdkBackendStack); 37 | -------------------------------------------------------------------------------- /cdk-stacks/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk-stacks.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 25 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 26 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 27 | "@aws-cdk/aws-iam:minimizePolicies": true, 28 | "@aws-cdk/core:target-partitions": [ 29 | "aws", 30 | "aws-cn" 31 | ] 32 | } 33 | } -------------------------------------------------------------------------------- /cdk-stacks/config.params.json: -------------------------------------------------------------------------------- 1 | { 2 | "CdkAppName": "ConnectGRAdmin", 3 | "CdkBackendStack": "ConnectGlobalResiliencyAdminBackend", 4 | "CdkFrontendStack": "ConnectGlobalResiliencyAdminFrontend", 5 | "CdkPipelineStack": "ConnectGlobalResiliencyAdminPipeline", 6 | "WebAppRootPrefix": "WebAppRoot/", 7 | "WebAppStagingPrefix": "WebAppStaging/", 8 | "hierarchy": "/ConnectGlobalResiliencyAdmin/", 9 | "parameters": [ 10 | { 11 | "name": "cognitoDomainPrefix", 12 | "cliFormat": "cognito-domain-prefix", 13 | "description": "Amazon Cognito hosted UI domain prefix, where users will be redirected during the login process. The domain prefix has to be unique. It can include only lowercase letters, numbers, and hyphens. Do not use a hyphen for the first or last character. Use periods to separate subdomain names. You can't use the text aws, amazon, or cognito in the domain prefix.", 14 | "required": true 15 | }, 16 | { 17 | "name": "webappAPIAllowedOrigins", 18 | "cliFormat": "webapp-api-allowed-origins", 19 | "description": "Please provide the domain of your web application, to allow CORS. For example: https://aaaabbbbcccc.cloudfront.net", 20 | "defaultValue": "*", 21 | "required": true 22 | }, 23 | { 24 | "name": "cognitoSAMLEnabled", 25 | "cliFormat": "cognito-saml-enabled", 26 | "description": "If SSO/SAML is enabled, set to true, otherwise set to false", 27 | "defaultValue": false, 28 | "required": true, 29 | "boolean": true 30 | }, 31 | { 32 | "name": "cognitoSAMLIdentityProviderURL", 33 | "cliFormat": "cognito-saml-identity-provider-url", 34 | "description": "If SSO/SAML was enabled, please provide IdP Metadata URL. For example: https://portal.sso.{region}.amazonaws.com/saml/metadata/aaabbbcccdddeee", 35 | "required": true, 36 | "parent": "cognitoSAMLEnabled" 37 | }, 38 | { 39 | "name": "cognitoSAMLIdentityProviderName", 40 | "cliFormat": "cognito-saml-identity-provider-name", 41 | "description": "If SSO/SAML was enabled, please provide the Identity Provide name. For example: AWSSSO", 42 | "required": true, 43 | "parent": "cognitoSAMLEnabled" 44 | }, 45 | { 46 | "name": "cognitoSAMLCallbackUrls", 47 | "cliFormat": "cognito-saml-callback-urls", 48 | "description": "If SSO/SAML was enabled, please provide a callback URL for the Amazon Cognito authorization server to call after users are authenticated. This should be set to your application root URL. For example: https://aaaabbbbcccc.cloudfront.net", 49 | "required": true, 50 | "parent": "cognitoSAMLEnabled" 51 | }, 52 | { 53 | "name": "cognitoSAMLLogoutUrls", 54 | "cliFormat": "cognito-saml-logout-urls", 55 | "description": "If SSO/SAML was enabled, please provide a logout URL where user is to be redirected after logging out.", 56 | "required": true, 57 | "parent": "cognitoSAMLEnabled" 58 | } 59 | ], 60 | "tags": { 61 | "project": "Amazon Connect Global Resiliency Dashboard starter project", 62 | "os-code-source": "https://github.com/aws-samples/amazon-connect-global-resiliency" 63 | } 64 | } -------------------------------------------------------------------------------- /cdk-stacks/configure.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const configParams = require('./config.params.json') 5 | const { SSMClient, PutParameterCommand, GetParametersCommand, DeleteParametersCommand } = require('@aws-sdk/client-ssm') 6 | const ssmClient = new SSMClient() 7 | const fs = require('fs') 8 | const readline = require("readline") 9 | 10 | const SSM_NOT_DEFINED = 'not-defined' 11 | let VERBOSE = false 12 | 13 | 14 | function displayHelp() { 15 | console.log(`\nThis script gets deployment parameters and stores the parameters to AWS System Manager Parameter Store \n`) 16 | 17 | console.log(`Usage:\n`) 18 | console.log(`-i \t Run in interactive mode`) 19 | console.log(`-l \t When running in interactive mode, load the current parameters from AWS System Manager Parameter Store`) 20 | console.log(`-t \t Run Test mode (only creates config.cache.json, but it does not store parameters to AWS System Manager Parameter Store)`) 21 | console.log(`-d \t Delete all AWS SSM Parameters (after CDK stack was destroyed)`) 22 | displayParametersHelp() 23 | process.exit(0) 24 | } 25 | 26 | function displayParametersHelp() { 27 | console.log(`\nParameters: \n`) 28 | configParams.parameters.forEach(param => { 29 | console.log(`--${param.cliFormat} [${param.required ? 'required' : 'optional'}${param.parent ? ' when ' + getParentObject(param).cliFormat : ''}] \n\t\t${wrapText(param.description, 80)}\n`) 30 | }) 31 | } 32 | 33 | function wrapText(s, w) { 34 | return s.replace(new RegExp(`(?![^\\n]{1,${w}}$)([^\\n]{1,${w}})\\s`, 'g'), '$1\n\t\t') 35 | } 36 | 37 | function getParentObject(param) { 38 | return configParams.parameters.find(parent => parent.name === param.parent) 39 | } 40 | 41 | function isParentEnabled(param) { 42 | return configParams.parameters.find(parent => parent.name === param.parent).value === true 43 | } 44 | 45 | function isNotDefined(param) { 46 | return param.value === SSM_NOT_DEFINED 47 | } 48 | 49 | function isUndefinedNullEmpty(value) { 50 | return value === undefined || value === null || (typeof value === 'string' && value.trim() === '') 51 | } 52 | 53 | function throwParameterRequired(param) { 54 | 55 | throw new Error(`Required parameter not provided: [${param.cliFormat}]`) 56 | } 57 | 58 | async function loadParametersSSM() { 59 | console.log(`\nLoading current parameters from AWS System Manager Parameter Store\n`); 60 | 61 | const chunkedParameters = chunkArray(configParams.parameters, 10); 62 | for (let i = 0; i < chunkedParameters.length; i++) { 63 | const getParametersResult = await getParametersSSMBatch(chunkedParameters[i]).catch(error => { 64 | console.log(`ERROR: getParametersSSMBatch: ${error.message}`); return undefined; 65 | }); 66 | 67 | getParametersResult?.forEach(loadedParam => { 68 | if (loadedParam.Value && loadedParam.Name) { 69 | const configParam = configParams.parameters.find(configParam => configParam.name === /[^/]*$/.exec(loadedParam.Name)[0]); 70 | configParam.value = parseParam(loadedParam.Value); 71 | } 72 | }); 73 | 74 | if (i !== 0 && i % 2 === 0) await wait(1000); 75 | } 76 | 77 | console.log(`\nLoad completed\n`); 78 | } 79 | 80 | async function getParametersSSMBatch(parametersArray) { 81 | if (parametersArray?.length < 1 || parametersArray?.length > 10) throw new Error(`getParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 10 items`); 82 | 83 | const paramNamesArray = parametersArray.map((param) => `${configParams.hierarchy}${param.name}`); 84 | 85 | const getParametersResult = await ssmClient.send(new GetParametersCommand({ Names: paramNamesArray })); 86 | 87 | getParametersResult?.InvalidParameters?.forEach(invalidParam => { console.log(`Error loading parameter: ${invalidParam}`); }); 88 | 89 | return getParametersResult?.Parameters; 90 | } 91 | 92 | async function storeParametersSSM() { 93 | console.log(`\nStoring parameters to AWS System Manager Parameter Store\n`) 94 | 95 | const chunkedParameters = chunkArray(configParams.parameters, 5); 96 | for (let i = 0; i < chunkedParameters.length; i++) { 97 | await putParametersSSMBatch(chunkedParameters[i]).catch(error => { 98 | console.log(`ERROR: putParametersSSMBatch: ${error.message}`); 99 | }); 100 | await wait(1000); 101 | } 102 | 103 | console.log(`\nStore completed\n`) 104 | } 105 | 106 | async function putParametersSSMBatch(parametersArray) { 107 | if (parametersArray?.length < 1 || parametersArray?.length > 5) throw new Error(`putParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 5 items`); 108 | 109 | for (const param of parametersArray) { 110 | console.log(`\nAWS SSM put ${configParams.hierarchy}${param.name} = ${param.value}`); 111 | //supports only String parameters 112 | const putParameterResult = await ssmClient.send(new PutParameterCommand({ Type: 'String', Name: `${configParams.hierarchy}${param.name}`, Value: param.boolean ? param.value.toString() : param.value, Overwrite: true })); 113 | console.log(`Stored param: ${configParams.hierarchy}${param.name} | tier: ${putParameterResult.Tier} | version: ${putParameterResult.Version}\n`); 114 | } 115 | } 116 | 117 | async function deleteParametersSSM() { 118 | console.log(`\nDeleting parameters to AWS System Manager Parameter Store\n`); 119 | 120 | const chunkedParameters = chunkArray(configParams.parameters, 10); 121 | for (let i = 0; i < chunkedParameters.length; i++) { 122 | await deleteParametersSSMBatch(chunkedParameters[i]).catch(error => { 123 | console.log(`ERROR: deleteParametersSSMBatch: ${error.message}`); 124 | }); 125 | await wait(1000); 126 | } 127 | 128 | console.log(`\nDelete completed\n`) 129 | process.exit(0); 130 | } 131 | 132 | async function deleteParametersSSMBatch(parametersArray) { 133 | if (parametersArray?.length < 1 || parametersArray?.length > 10) throw new Error(`deleteParametersSSMBatch -> parametersArray -> Minimum number of 1 item. Maximum number of 10 items`); 134 | 135 | const paramNamesArray = parametersArray.map((param) => `${configParams.hierarchy}${param.name}`); 136 | const deleteParametersResult = await ssmClient.send(new DeleteParametersCommand({ Names: paramNamesArray })); 137 | 138 | deleteParametersResult?.InvalidParameters?.forEach(invalidParam => { console.log(`Error deleting parameter: ${invalidParam}`); }); 139 | 140 | deleteParametersResult?.DeletedParameters?.forEach(deletedParam => { console.log(`Deleted param: ${deletedParam}`) }); 141 | } 142 | 143 | function chunkArray(inputArray, chunkSize) { 144 | let index = 0; 145 | const arrayLength = inputArray.length; 146 | let resultArray = []; 147 | 148 | for (index = 0; index < arrayLength; index += chunkSize) { 149 | let chunkItem = inputArray.slice(index, index + chunkSize); 150 | resultArray.push(chunkItem); 151 | } 152 | 153 | return resultArray; 154 | } 155 | 156 | function wait(time) { 157 | return new Promise((resolve) => { 158 | setTimeout(() => resolve(), time); 159 | }); 160 | } 161 | 162 | async function writeConfigCacheJSON() { 163 | console.log(`\nWriting current parameters to config.cache.json\n`) 164 | 165 | const configCache = {} 166 | for (const param of configParams.parameters) { 167 | configCache[`${configParams.hierarchy}${param.name}`] = param.value 168 | } 169 | 170 | fs.writeFileSync('config.cache.json', JSON.stringify(configCache, null, '\t')) 171 | 172 | console.log(`\nWrite completed\n`) 173 | } 174 | 175 | function checkRequiredParameters() { 176 | 177 | for (const param of configParams.parameters) { 178 | if (param.required && !param.parent && isNotDefined(param)) { 179 | throwParameterRequired(param) 180 | } 181 | 182 | if (param.required && param.parent && isParentEnabled(param) && isNotDefined(param)) { 183 | throwParameterRequired(param) 184 | } 185 | } 186 | } 187 | 188 | function initParameters() { 189 | for (const param of configParams.parameters) { 190 | param.value = isUndefinedNullEmpty(param.defaultValue) ? SSM_NOT_DEFINED : param.defaultValue 191 | } 192 | } 193 | 194 | function displayInputParameters() { 195 | console.log(`\nInput parameters:\n`) 196 | 197 | for (const param of configParams.parameters) { 198 | console.log(`${param.cliFormat} = ${param.value}`) 199 | } 200 | } 201 | 202 | function parseParam(value) { 203 | let tValue = value.trim() 204 | if (typeof tValue === 'string' && tValue.toLocaleLowerCase() === 'true') return true 205 | if (typeof tValue === 'string' && tValue.toLowerCase() === 'false') return false 206 | return tValue 207 | } 208 | 209 | function getArgs() { 210 | const argFlags = {} 211 | const argParams = {} 212 | 213 | process.argv 214 | .slice(2, process.argv.length) 215 | .forEach(arg => { 216 | // long args 217 | if (arg.slice(0, 2) === '--') { 218 | const longArg = arg.split('='); 219 | const longArgFlag = longArg[0].slice(2, longArg[0].length); 220 | const longArgValue = longArg.length > 1 ? parseParam(longArg[1]) : true; 221 | argParams[longArgFlag] = longArgValue; 222 | } 223 | // flags 224 | else if (arg[0] === '-') { 225 | const flags = arg.slice(1, arg.length).split(''); 226 | flags.forEach(flag => { 227 | argFlags[flag] = true; 228 | }); 229 | } 230 | }); 231 | return { argFlags: argFlags, argParams: argParams }; 232 | } 233 | 234 | async function runInteractive(loadSSM = false) { 235 | if (loadSSM) { 236 | await loadParametersSSM() 237 | } 238 | await promptForParameters() 239 | } 240 | 241 | function buildQuestion(question, rl) { 242 | return new Promise((res, rej) => { 243 | rl.question(question, input => { 244 | res(input); 245 | }) 246 | }); 247 | } 248 | 249 | async function promptForParameters() { 250 | console.log(`\nPlease provide your parameters:\n`) 251 | 252 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 253 | 254 | for (const param of configParams.parameters) { 255 | if (!param.parent || (param.parent && isParentEnabled(param))) { 256 | const input = await buildQuestion(`${param.cliFormat} [${param.value}]`, rl) 257 | if (input.trim() !== '') { 258 | param.value = parseParam(input) 259 | } 260 | } 261 | } 262 | 263 | rl.close() 264 | } 265 | 266 | function processArgParams(argParams) { 267 | 268 | for (const param of configParams.parameters) { 269 | const argValue = argParams[param.cliFormat] 270 | if (argValue !== undefined) { 271 | param.value = argValue 272 | } 273 | } 274 | } 275 | 276 | async function run() { 277 | try { 278 | const { argFlags, argParams } = getArgs(); 279 | 280 | if (argFlags['v'] === true) { 281 | VERBOSE = true 282 | } 283 | 284 | if (argFlags['h'] === true) { 285 | return displayHelp() 286 | } 287 | 288 | if (argFlags['d'] === true) { 289 | return await deleteParametersSSM() 290 | } 291 | 292 | if (argFlags['t'] === true) { 293 | console.log(`\nRunning in test mode\n`) 294 | } 295 | 296 | initParameters() 297 | 298 | if (argFlags['i'] === true) { 299 | console.log(`\nRunning in interactive mode\n`) 300 | await runInteractive(argFlags['l']) 301 | } 302 | else { 303 | processArgParams(argParams) 304 | } 305 | 306 | displayInputParameters() 307 | 308 | checkRequiredParameters() 309 | 310 | writeConfigCacheJSON() 311 | 312 | if (argFlags['t'] !== true) { 313 | await storeParametersSSM() 314 | } 315 | 316 | console.log(`\nConfiguration complete, review your parameters in config.cache.json\n`) 317 | process.exit(0) 318 | } 319 | catch (error) { 320 | console.error(`\nError: ${error.message}\n`) 321 | if (VERBOSE) console.log(error) 322 | process.exit(1) 323 | } 324 | 325 | } 326 | 327 | run() 328 | 329 | 330 | 331 | -------------------------------------------------------------------------------- /cdk-stacks/jest.config.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | module.exports = { 5 | roots: ['/test'], 6 | testMatch: ['**/*.test.ts'], 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest' 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /cdk-stacks/lambda-layer/nodejs/node_modules/querystring/.History.md.un~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/cdk-stacks/lambda-layer/nodejs/node_modules/querystring/.History.md.un~ -------------------------------------------------------------------------------- /cdk-stacks/lambda-layer/nodejs/node_modules/querystring/.Readme.md.un~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/cdk-stacks/lambda-layer/nodejs/node_modules/querystring/.Readme.md.un~ -------------------------------------------------------------------------------- /cdk-stacks/lambda-layer/nodejs/node_modules/querystring/.package.json.un~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/cdk-stacks/lambda-layer/nodejs/node_modules/querystring/.package.json.un~ -------------------------------------------------------------------------------- /cdk-stacks/lambda-layer/nodejs/node_modules/querystring/test/.index.js.un~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/cdk-stacks/lambda-layer/nodejs/node_modules/querystring/test/.index.js.un~ -------------------------------------------------------------------------------- /cdk-stacks/lambda-layer/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-sdk-lambda-layer", 3 | "version": "0.0.1", 4 | "description": "Amazon Connect Global Resiliency starter project - lambda layer", 5 | "scripts": {}, 6 | "author": "prod-apps-builder-team", 7 | "license": "MIT-0", 8 | "dependencies": { 9 | "aws-sdk": "^2.1360.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 12, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/constants/PairedRegions.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | /** 5 | * Currently supported regions as of last release of this repo 6 | */ 7 | const pairedRegionMap = { 8 | "us-east-1": "us-west-2", 9 | "us-west-2": "us-east-1" 10 | } 11 | 12 | export default pairedRegionMap 13 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/constants/Tags.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | /** 5 | * Tags to be applied on API calls from dashboard that accept them 6 | */ 7 | const tags = { 8 | "created-from": "Amazon Connect Global Resiliency Dashboard" 9 | } 10 | 11 | export default tags -------------------------------------------------------------------------------- /cdk-stacks/lambdas/custom-resources/frontend-config/index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: MIT-0 5 | 6 | import boto3 7 | import logging 8 | import json 9 | import contextlib 10 | from urllib.request import Request, urlopen 11 | from uuid import uuid4 12 | import tempfile 13 | import os 14 | from zipfile import ZipFile 15 | import shutil 16 | 17 | logger = logging.getLogger() 18 | logger.setLevel(logging.INFO) 19 | 20 | CFN_SUCCESS = "SUCCESS" 21 | CFN_FAILED = "FAILED" 22 | 23 | s3 = boto3.client("s3") 24 | 25 | 26 | def create(bucket_name, web_app_staging_object_prefix, web_app_root_object_prefix, object_key, object_content, object_content_type): 27 | 28 | workdir = tempfile.mkdtemp() 29 | logger.info("| workdir: %s" % workdir) 30 | 31 | raw_file_complete = os.path.join(workdir, object_key) 32 | logger.info("| write file: %s" % raw_file_complete) 33 | raw_file = open(raw_file_complete, 'w') 34 | raw_file.write(object_content) 35 | raw_file.close() 36 | 37 | zip_file_name = f"{os.path.splitext(object_key)[0]}.zip" 38 | zip_file_complete = os.path.join(workdir, zip_file_name) 39 | logger.info("| zip into file: %s" % zip_file_complete) 40 | ZipFile(zip_file_complete, mode='w').write( 41 | raw_file_complete, arcname=object_key) 42 | 43 | # upload to WebAppStaging 44 | staging_object_url = f"s3://{bucket_name}/{web_app_staging_object_prefix}{zip_file_name}" 45 | logger.info(f"Uploading frontend config to {staging_object_url}") 46 | s3.upload_file(zip_file_complete, bucket_name, 47 | f"{web_app_staging_object_prefix}{zip_file_name}") 48 | 49 | # upload to WebAppRoot 50 | root_object_url = f"s3://{bucket_name}/{web_app_root_object_prefix}{object_key}" 51 | logger.info(f"Uploading frontend config to {root_object_url}") 52 | s3.upload_file(raw_file_complete, bucket_name, 53 | f"{web_app_root_object_prefix}{object_key}", ExtraArgs={'Metadata': {'ContentType': object_content_type}}) 54 | 55 | shutil.rmtree(workdir) 56 | 57 | 58 | def delete(bucket_name, web_app_staging_object_prefix, object_key): 59 | 60 | zip_file_name = f"{os.path.splitext(object_key)[0]}.zip" 61 | 62 | object_url = f"s3://{bucket_name}/{web_app_staging_object_prefix}{zip_file_name}" 63 | logger.info(f"Removing frontend config from {object_url}") 64 | 65 | s3.delete_object( 66 | Bucket=bucket_name, 67 | Key=f"{web_app_staging_object_prefix}{zip_file_name}" 68 | ) 69 | 70 | 71 | def handler(event, context): 72 | 73 | def cfn_error(message=None): 74 | logger.error("| cfn_error: %s" % message) 75 | cfn_send(event, context, CFN_FAILED, reason=message) 76 | 77 | try: 78 | logger.info(event) 79 | 80 | # cloudformation request type (create/update/delete) 81 | request_type = event['RequestType'] 82 | 83 | # extract resource properties 84 | props = event['ResourceProperties'] 85 | old_props = event.get('OldResourceProperties', {}) 86 | physical_id = event.get('PhysicalResourceId', None) 87 | 88 | bucket_name = props["BucketName"] 89 | object_key = props["ObjectKey"] 90 | 91 | # if we are creating a new resource, allocate a physical id for it 92 | # otherwise, we expect physical id to be relayed by cloudformation 93 | if request_type == "Create": 94 | physical_id = "vce.frontend-config.%s" % str(uuid4()) 95 | else: 96 | if not physical_id: 97 | cfn_error( 98 | "invalid request: request type is '%s' but 'PhysicalResourceId' is not defined" % request_type) 99 | return 100 | 101 | if request_type == "Create" or request_type == "Update": 102 | web_app_staging_object_prefix = props["WebAppStagingObjectPrefix"] 103 | web_app_root_object_prefix = props["WebAppRootObjectPrefix"] 104 | object_content = props.get("Content") 105 | object_content_type = props.get("ContentType") 106 | create(bucket_name, web_app_staging_object_prefix, web_app_root_object_prefix, object_key, 107 | object_content, object_content_type) 108 | 109 | if request_type == "Delete": 110 | web_app_staging_object_prefix = props["WebAppStagingObjectPrefix"] 111 | delete(bucket_name, web_app_staging_object_prefix, object_key) 112 | 113 | cfn_send(event, context, CFN_SUCCESS, physicalResourceId=physical_id) 114 | 115 | except KeyError as e: 116 | cfn_error("invalid request. Missing key %s" % str(e)) 117 | except Exception as e: 118 | logger.exception(e) 119 | cfn_error(str(e)) 120 | 121 | # --------------------------------------------------------------------------------------------------- 122 | # sends a response to cloudformation 123 | 124 | 125 | def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): 126 | 127 | responseUrl = event['ResponseURL'] 128 | logger.info(responseUrl) 129 | 130 | responseBody = {} 131 | responseBody['Status'] = responseStatus 132 | responseBody['Reason'] = reason or ( 133 | 'See the details in CloudWatch Log Stream: ' + context.log_stream_name) 134 | responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name 135 | responseBody['StackId'] = event['StackId'] 136 | responseBody['RequestId'] = event['RequestId'] 137 | responseBody['LogicalResourceId'] = event['LogicalResourceId'] 138 | responseBody['NoEcho'] = noEcho 139 | responseBody['Data'] = responseData 140 | 141 | body = json.dumps(responseBody) 142 | logger.info("| response body:\n" + body) 143 | 144 | headers = { 145 | 'content-type': '', 146 | 'content-length': str(len(body)) 147 | } 148 | 149 | try: 150 | request = Request(responseUrl, method='PUT', data=bytes( 151 | body.encode('utf-8')), headers=headers) 152 | with contextlib.closing(urlopen(request)) as response: 153 | logger.info("| status code: " + response.reason) 154 | except Exception as e: 155 | logger.error("| unable to send response to CloudFormation") 156 | logger.exception(e) 157 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectCreateTrafficDistributionGroup.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | const connectCreateTDGResult = await ConnectService.createTrafficDistributionGroup(req.body); 17 | console.info('Connect Create TDG Result: ', connectCreateTDGResult); 18 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect Create Traffic Distribution Group succeeded!', data: connectCreateTDGResult }); 19 | } 20 | catch (error) { 21 | console.error(error); 22 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectDeleteTrafficDistributionGroup.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | const connectDeleteTrafficDistributionGroupResult = await ConnectService.deleteTrafficDistributionGroup(req.body['trafficDistributionGroupId']); 17 | console.info('Connect Delete Traffic Distribution Group Result: ', connectDeleteTrafficDistributionGroupResult); 18 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect Delete Traffic Distribution Group succeeded!', data: connectDeleteTrafficDistributionGroupResult }); 19 | } 20 | catch (error) { 21 | console.error(error); 22 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 23 | } 24 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectListInstances.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const connectListInstancesResult = await ConnectService.listInstances(); 16 | console.info('Connect List Instances Result: ', connectListInstancesResult); 17 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect List Instances succeeded!', data: connectListInstancesResult }); 18 | } 19 | catch (error) { 20 | console.error(error); 21 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 22 | } 23 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectListPhoneNumbers.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | 17 | const connectListPhoneNumbersResult = await ConnectService.listPhoneNumbers(req.body); 18 | console.info('Connect List Phone Numbers Result: ', connectListPhoneNumbersResult); 19 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect List Phone Numbers succeeded!', data: connectListPhoneNumbersResult }); 20 | } 21 | catch (error) { 22 | console.error(error); 23 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 24 | } 25 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectListTrafficDistributionGroups.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | const connectListTDGResult = await ConnectService.listTrafficDistributionGroups(req.queryStringParameters['instanceId'], req.queryStringParameters['maxResults'], req.queryStringParameters['nextToken']); 17 | console.info('Connect List Traffic Distribution Groups Result: ', connectListTDGResult); 18 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect List Traffic Distribution Groups succeeded!', data: connectListTDGResult.TrafficDistributionGroupSummaryList }); 19 | } 20 | catch (error) { 21 | console.error(error); 22 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 23 | } 24 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectReplicateInstance.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | const connectReplicateInstanceResult = await ConnectService.replicateInstance(req.body['instanceId'], req.body['replicaAlias']) 17 | console.info('Connect Replicate Instance Result: ', connectReplicateInstanceResult); 18 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect Replicate Instance succeeded!', data: connectReplicateInstanceResult }); 19 | } 20 | catch (error) { 21 | console.error(error); 22 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 23 | } 24 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectShowInstance.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | const connectShowInstanceResult = await ConnectService.showInstance(req.queryStringParameters['instanceId'], currentUser); 17 | console.info('Connect Show Instance Result: ', connectShowInstanceResult); 18 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect Show Instance succeeded!', data: connectShowInstanceResult }); 19 | } 20 | catch (error) { 21 | console.error(error); 22 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 23 | } 24 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectShowTrafficDistributionGroup.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | 17 | //Information we need is spread across two calls. 18 | const connectDescribeTrafficDistributionGroupResult = await ConnectService.describeTrafficDistributionGroup(req.queryStringParameters['trafficDistributionGroupId']) 19 | console.info('Connect Describe Traffic Distribution Group Result: ', connectDescribeTrafficDistributionGroupResult); 20 | 21 | const connectGetTrafficDistributionResult = await ConnectService.getTrafficDistribution(req.queryStringParameters['trafficDistributionGroupId']) 22 | console.info('Connect List Traffic Distribution Result: ', connectGetTrafficDistributionResult); 23 | 24 | connectDescribeTrafficDistributionGroupResult.TrafficDistributionGroup.TrafficDistribution = connectGetTrafficDistributionResult; 25 | 26 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect Show Traffic Distribution Group Succeeded!', data: connectDescribeTrafficDistributionGroupResult }); 27 | } 28 | catch (error) { 29 | console.error(error); 30 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 31 | } 32 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectUpdatePhoneNumbers.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | 17 | const connectUpdatePhoneNumbersResult = await ConnectService.updatePhoneNumbers(req.body.targetArn, req.body.phoneNumberIds); 18 | console.info('Connect Update Phone Numbers Result: ', connectUpdatePhoneNumbersResult); 19 | return LambdaUtility.buildLambdaResponse(context, 207, connectUpdatePhoneNumbersResult); 20 | 21 | } 22 | catch (error) { 23 | console.error(error); 24 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/handlers/ConnectAPI/connectUpdateTrafficDistribution.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const LambdaUtility = require('../../lib/LambdaUtility'); 5 | const AuthUtility = require('../../lib/AuthUtility'); 6 | const ConnectService = require('../../services/ConnectService'); 7 | 8 | exports.handler = async (event, context) => { 9 | 10 | try { 11 | console.debug(`Event: `, event); 12 | const currentUser = await AuthUtility.getCurrentUser(event); 13 | console.info(`Current user: `, currentUser); 14 | 15 | const req = LambdaUtility.parseEventBody(event); 16 | const connectUpdateTrafficDistributionResult = await ConnectService.updateTrafficDistribution(req.body); 17 | console.info('Connect Update Traffic Distribution Result: ', connectUpdateTrafficDistributionResult); 18 | return LambdaUtility.buildLambdaResponse(context, 200, { success: 'Connect Update Traffic Distribution succeeded!', data: connectUpdateTrafficDistributionResult }); 19 | } 20 | catch (error) { 21 | console.error(error); 22 | return LambdaUtility.buildLambdaResponse(context, error.statusCode || 500, { message: error.message }); 23 | } 24 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/lib/AuthUtility.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | //const ErrorHandler = require('../lib/Error'); 5 | 6 | const getUserFromJWT = async (cognitoIdToken) => { 7 | 8 | 9 | const tokenSections = cognitoIdToken.split('.'); 10 | if (tokenSections.length < 2) { 11 | throw new Error('Requested token is invalid'); 12 | } 13 | const payloadJSON = Buffer.from(tokenSections[1], 'base64').toString('utf8'); 14 | const payload = JSON.parse(payloadJSON); 15 | 16 | return { 17 | username: payload['cognito:username'], 18 | cognito_groups: payload['cognito:groups'], 19 | email: payload['email'], 20 | } 21 | } 22 | 23 | 24 | const getCurrentUser = async (req) => { 25 | return await getUserFromJWT(req.headers.authorization); 26 | } 27 | 28 | module.exports = { 29 | getCurrentUser 30 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/lib/CommonUtility.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const uuid = () => { 5 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 6 | let r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); 7 | return v.toString(16); 8 | }); 9 | } 10 | 11 | const validateEmailAddress = (email) => { 12 | const emailRegEx = /^[^\s@]+@[^\s@]+$/ 13 | return emailRegEx.test(email) 14 | } 15 | 16 | const makeComparator = (key, order = 'asc') => { 17 | return (a, b) => { 18 | if (!Object.prototype.hasOwnProperty.call(a, key) || !Object.prototype.hasOwnProperty.call(b, key)) return 0; 19 | 20 | const aVal = ((typeof a[key] === 'string') ? a[key].toUpperCase() : a[key]); 21 | const bVal = ((typeof b[key] === 'string') ? b[key].toUpperCase() : b[key]); 22 | 23 | let comparison = 0; 24 | if (aVal > bVal) comparison = 1; 25 | if (aVal < bVal) comparison = -1; 26 | 27 | return order === 'desc' ? (comparison * -1) : comparison 28 | }; 29 | } 30 | 31 | const wait = (time) => { 32 | return new Promise((resolve) => { 33 | setTimeout(() => resolve(), time); 34 | }); 35 | } 36 | 37 | const customBackoff = (retryCount) => { 38 | const timeToWait = 2 ** (retryCount+1) * 1000; 39 | const jitter = Math.floor(Math.random() * (1000 - 100 + 1) + 100) 40 | const waitWithJitter = timeToWait + jitter 41 | console.debug(`retry count: ${retryCount}, timeToWait: ${timeToWait}, jitter: ${jitter} waiting: ${waitWithJitter}ms`) 42 | return waitWithJitter 43 | } 44 | 45 | 46 | module.exports = { 47 | uuid, 48 | validateEmailAddress, 49 | makeComparator, 50 | wait, 51 | customBackoff 52 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/lib/Error.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | class ErrorHandler extends Error { 5 | constructor(statusCode, message) { 6 | super(); 7 | this.statusCode = statusCode; 8 | this.message = message; 9 | } 10 | } 11 | 12 | module.exports = ErrorHandler -------------------------------------------------------------------------------- /cdk-stacks/lambdas/lib/LambdaUtility.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const buildLambdaResponse = (context, statusCode = 200, body = {}, headers = { 'Content-Type': 'application/json' }) => { 5 | body.reqId = context.awsRequestId 6 | return { 7 | statusCode: statusCode, 8 | body: JSON.stringify(body), 9 | headers: headers 10 | } 11 | } 12 | 13 | const parseEventBody = (event) => { 14 | if (event.headers['content-type'] && event.headers['content-type'].match(/application\/json/i)) { 15 | const parsedBody = JSON.parse(event.body); 16 | return { ...event, body: parsedBody } 17 | } 18 | return event; 19 | } 20 | 21 | module.exports = { 22 | buildLambdaResponse, 23 | parseEventBody 24 | } -------------------------------------------------------------------------------- /cdk-stacks/lambdas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambdas", 3 | "version": "1.0.0", 4 | "description": "Amazon Connect Global Resiliency starter project", 5 | "main": "index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "build": "esbuild handlers/*.js --bundle --external:aws-sdk --outdir=build --target=node16 --platform=node" 11 | }, 12 | "author": "prod-apps-builder-team", 13 | "license": "MIT-0", 14 | "devDependencies": { 15 | "aws-sdk": "^2.1212.0", 16 | "esbuild": "^0.15.7", 17 | "eslint": "^8.23.0" 18 | }, 19 | "dependencies": { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cdk-stacks/lambdas/services/ConnectService.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const ErrorHandler = require('../lib/Error'); 5 | const ConnectClient = require('aws-sdk/clients/connect'); 6 | 7 | import pairedRegionMap from '../constants/PairedRegions' 8 | const currentRegion = process.env.AWS_REGION 9 | const pairedRegion = pairedRegionMap[currentRegion] 10 | 11 | import tags from '../constants/Tags' 12 | import customBackoff from '../lib/CommonUtility' 13 | 14 | const Connect = new ConnectClient({ 15 | maxRetries: 3, 16 | retryDelayOptions: { customBackoff }, 17 | }); 18 | 19 | const listInstances = async () => { 20 | 21 | const connectResult = await Connect.listInstances().promise().catch(error => { 22 | console.error('Connect.listInstances: ', error); 23 | throw new ErrorHandler(error.statusCode, error.message); 24 | }); 25 | 26 | return connectResult?.InstanceSummaryList; 27 | } 28 | 29 | const showInstance = async (instanceId) => { 30 | 31 | const params = { 32 | InstanceId: instanceId 33 | } 34 | 35 | const describeInstanceResult = await Connect.describeInstance(params).promise().catch(error => { 36 | console.error('Connect.describeInstance: ', error); 37 | throw new ErrorHandler(error.statusCode, error.message); 38 | }); 39 | 40 | const instanceDetails = describeInstanceResult.Instance; 41 | 42 | //CHECK FOR REPLICA 43 | console.log('currentRegion', currentRegion) 44 | console.log('pairedRegion', pairedRegion) 45 | 46 | if (pairedRegion && currentRegion !== pairedRegion) { 47 | 48 | const ConnectPairedRegion = new ConnectClient({region: pairedRegion}); 49 | let unknownStatus = false 50 | const describeInstanceResultPairedRegion = await ConnectPairedRegion.describeInstance(params).promise().catch(error => { 51 | if (error.name === "ResourceNotFoundException") { 52 | console.log('ReplicatedInstance not found, continuing'); 53 | } 54 | else { 55 | unknownStatus = true 56 | // If another error has been returned it may indicate an outage in the paired region, 57 | // we should ensure this does not prevent returning the information for this instance in this region 58 | console.log('Error evaluating replica, continuing', error.statusCode, error.message); 59 | } 60 | }); 61 | 62 | if (describeInstanceResultPairedRegion && describeInstanceResultPairedRegion.Instance) { 63 | instanceDetails['Replicated'] = "true" 64 | instanceDetails['ReplicaAlias'] = describeInstanceResultPairedRegion.Instance.InstanceAlias 65 | 66 | const instanceCreationTime = instanceDetails.CreatedTime 67 | const pairedInstanceCreatedTime = describeInstanceResultPairedRegion.Instance.CreatedTime 68 | instanceDetails['PrimaryReplica'] = instanceCreationTime < pairedInstanceCreatedTime 69 | } 70 | else if (unknownStatus) { 71 | // If an error has been returned it may indicate an outage in the paired region, 72 | // we should ensure we are not locking the front end by incorrectly returning false for replication 73 | instanceDetails['Replicated'] = "unable to determine" 74 | } 75 | else { 76 | instanceDetails['Replicated'] = "false" 77 | } 78 | } 79 | else { 80 | instanceDetails['Replicated'] = "invalid region for replication" 81 | } 82 | 83 | return instanceDetails; 84 | } 85 | 86 | const replicateInstance = async (instanceId, replicaAlias) => { 87 | const params = { 88 | InstanceId: instanceId, 89 | ReplicaRegion: pairedRegion, 90 | ReplicaAlias: replicaAlias, 91 | //ClientToken: '' 92 | } 93 | console.info('Connect Replicate Instance Params', params) 94 | const connectResult = await Connect.replicateInstance(params).promise().catch(error => { 95 | console.error('Connect.replicateInstance: ', error); 96 | throw new ErrorHandler(error.statusCode, error.message); 97 | }); 98 | 99 | return connectResult; 100 | } 101 | 102 | const listTrafficDistributionGroups = async (instanceId, maxResults, nextToken) => { 103 | const params = { 104 | MaxResults: maxResults || 10, 105 | InstanceId: instanceId, 106 | NextToken: nextToken || '' 107 | } 108 | const connectResult = await Connect.listTrafficDistributionGroups(params).promise().catch(error => { 109 | console.error('Connect.listTrafficDistributionGroups: ', error); 110 | throw new ErrorHandler(error.statusCode, error.message); 111 | }); 112 | 113 | return connectResult; 114 | } 115 | 116 | const createTrafficDistributionGroup = async (trafficDistributionGroupDetails) => { 117 | 118 | //Add in tags, tags passed in will overwrite the standard hardcoded project tags 119 | trafficDistributionGroupDetails.Tags = {...tags, ...trafficDistributionGroupDetails.Tags} 120 | 121 | const connectResult = await Connect.createTrafficDistributionGroup(trafficDistributionGroupDetails).promise().catch(error => { 122 | console.error('Connect.createTrafficDistributionGroup: ', error); 123 | throw new ErrorHandler(error.statusCode, error.message); 124 | }); 125 | 126 | return connectResult; 127 | } 128 | 129 | 130 | const describeTrafficDistributionGroup = async (trafficDistributionGroupId) => { 131 | const params = { 132 | TrafficDistributionGroupId: trafficDistributionGroupId 133 | } 134 | const connectResult = await Connect.describeTrafficDistributionGroup(params).promise().catch(error => { 135 | console.error('Connect.describeTrafficDistributionGroup: ', error); 136 | throw new ErrorHandler(error.statusCode, error.message); 137 | }); 138 | 139 | return connectResult; 140 | 141 | } 142 | 143 | const getTrafficDistribution = async (trafficDistributionGroupId) => { 144 | const params = { 145 | Id: trafficDistributionGroupId 146 | } 147 | const connectResult = await Connect.getTrafficDistribution(params).promise().catch(error => { 148 | console.error('Connect.getTrafficDistribution: ', error); 149 | throw new ErrorHandler(error.statusCode, error.message); 150 | }); 151 | 152 | return connectResult; 153 | 154 | } 155 | 156 | const updateTrafficDistribution = async (trafficDistribution) => { 157 | const params = trafficDistribution; 158 | 159 | const connectResult = await Connect.updateTrafficDistribution(params).promise().catch(error => { 160 | console.error('Connect.updateTrafficDistribution: ', error); 161 | throw new ErrorHandler(error.statusCode, error.message); 162 | }); 163 | 164 | return connectResult; 165 | 166 | } 167 | 168 | 169 | const listPhoneNumbers = async (listPhoneNumberParams) => { 170 | const connectResult = await Connect.listPhoneNumbersV2(listPhoneNumberParams).promise().catch(error => { 171 | console.error('Connect.listPhoneNumbersV2: ', error); 172 | throw new ErrorHandler(error.statusCode, error.message); 173 | }); 174 | 175 | return connectResult; 176 | } 177 | 178 | 179 | const updatePhoneNumbers = async (targetArn, phoneNumberIds) => { 180 | 181 | if (phoneNumberIds.length > 25){ 182 | throw new ErrorHandler('400', `Only 25 phoneNumberIds are accepted, ${phoneNumberIds.length} were passed.`); 183 | } else { 184 | try{ 185 | let response = []; 186 | let successCnt = 0; 187 | let errorCnt = 0; 188 | 189 | for (let index = 0; index < phoneNumberIds.length; index++) { 190 | const phoneNumberId = phoneNumberIds[index]; 191 | const params = { 192 | PhoneNumberId: phoneNumberId, 193 | TargetArn: targetArn, 194 | } 195 | 196 | const connectUpdatePhoneNumbersResult = await Connect.updatePhoneNumber(params).promise().catch(error => { 197 | console.error('Connect.updatePhoneNumber: ', error); 198 | response.push({ 199 | message: 'error', 200 | resource: { 201 | phoneNumberId: phoneNumberId, 202 | error: error 203 | }, 204 | status: 500 205 | }) 206 | errorCnt++; 207 | }); 208 | 209 | console.debug('Connect Update Phone Numbers Result: ', connectUpdatePhoneNumbersResult); 210 | response.push({ 211 | message: 'success', 212 | resource: { 213 | phoneNumberId: phoneNumberId 214 | }, 215 | status: 200 216 | }) 217 | successCnt++; 218 | } 219 | 220 | // Following format from https://stackoverflow.com/questions/45442847/rest-api-response-in-partial-success 221 | return { 222 | data: response, 223 | metadata: { 224 | failure: errorCnt, 225 | success: successCnt, 226 | total: phoneNumberIds.length 227 | } 228 | }; 229 | } catch (err){ 230 | console.error('Connect.updatePhoneNumber: ', err); 231 | throw new ErrorHandler(err.statusCode, err.message); 232 | } 233 | } 234 | } 235 | 236 | const deleteTrafficDistributionGroup = async (trafficDistributionGroupId) => { 237 | const params = { 238 | TrafficDistributionGroupId: trafficDistributionGroupId 239 | } 240 | const connectResult = await Connect.deleteTrafficDistributionGroup(params).promise().catch(error => { 241 | console.error('Connect.DeleteTrafficDistributionGroup: ', error); 242 | throw new ErrorHandler(error.statusCode, error.message); 243 | }); 244 | 245 | return connectResult; 246 | 247 | } 248 | 249 | module.exports = { 250 | listInstances, 251 | showInstance, 252 | replicateInstance, 253 | listTrafficDistributionGroups, 254 | describeTrafficDistributionGroup, 255 | getTrafficDistribution, 256 | createTrafficDistributionGroup, 257 | listPhoneNumbers, 258 | updatePhoneNumbers, 259 | updateTrafficDistribution, 260 | deleteTrafficDistributionGroup 261 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/api/connectAPI-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import {NestedStack, NestedStackProps, Duration } from "aws-cdk-lib"; 5 | import {Construct} from "constructs"; 6 | 7 | import * as nodeLambda from "aws-cdk-lib/aws-lambda-nodejs"; 8 | import * as lambda from 'aws-cdk-lib/aws-lambda' 9 | import * as iam from 'aws-cdk-lib/aws-iam'; 10 | import * as apigw from 'aws-cdk-lib/aws-apigateway'; 11 | import * as apigw2 from "@aws-cdk/aws-apigatewayv2-alpha"; 12 | import * as apigw2Integrations from "@aws-cdk/aws-apigatewayv2-integrations-alpha"; 13 | import * as apigw2Authorizers from "@aws-cdk/aws-apigatewayv2-authorizers-alpha"; 14 | import {CfnStage} from "aws-cdk-lib/aws-apigatewayv2"; 15 | import * as cognito from 'aws-cdk-lib/aws-cognito' 16 | import * as logs from 'aws-cdk-lib/aws-logs' 17 | import * as path from "path"; 18 | import { NagSuppressions } from 'cdk-nag' 19 | 20 | export interface ConnectAPIStackProps extends NestedStackProps { 21 | readonly SSMParams: any; 22 | readonly cognitoUserPool: cognito.IUserPool; 23 | readonly cognitoUserPoolClient: cognito.IUserPoolClient; 24 | readonly cdkAppName: string; 25 | } 26 | 27 | export class ConnectAPIStack extends NestedStack { 28 | 29 | public readonly connectAPI: apigw2.IHttpApi; 30 | 31 | constructor(scope: Construct, id: string, props: ConnectAPIStackProps) { 32 | super(scope, id, props); 33 | 34 | NagSuppressions.addStackSuppressions(this, [ 35 | { 36 | id: 'AwsSolutions-IAM4', 37 | reason: 'This is the default Lambda Execution Policy which just grants writes to CloudWatch.' 38 | }, 39 | { 40 | id: 'AwsSolutions-IAM5', 41 | reason: 'These methods are used for multi-region configuration in Connect and need to work on multiple connect instances and regions. We have scoped these down as much as we can: https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonconnect.html' 42 | } 43 | ]) 44 | 45 | // this layer is only needed until the 2.1236 (or higher) version of aws-sdk is in the lambda runtime https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html 46 | const awsSdkLayer = new lambda.LayerVersion(this, 'awsSdkLayer', { 47 | code: lambda.Code.fromAsset(path.join(__dirname, '../../lambda-layer')), 48 | compatibleRuntimes: [lambda.Runtime.NODEJS_20_X], 49 | description: 'The AWS SDK version required for the new Global Resiliency apis', 50 | }); 51 | 52 | // Describe Directory permissions is required for some Amazon Connect operations 53 | const describeDirectories = new iam.Policy(this, 'DescribeDirectoriesAccess', { 54 | statements: [ 55 | new iam.PolicyStatement({ 56 | effect: iam.Effect.ALLOW, 57 | actions: ["ds:DescribeDirectories"], 58 | resources: ['*'] 59 | }) 60 | ] 61 | }) 62 | 63 | const connectListInstancesLambda = new nodeLambda.NodejsFunction(this, 'ConnectListInstancesLambda', { 64 | functionName: `${props.cdkAppName}-ConnectListInstancesLambda`, 65 | runtime: lambda.Runtime.NODEJS_20_X, 66 | entry: 'lambdas/handlers/ConnectAPI/connectListInstances.js', 67 | timeout: Duration.seconds(30), 68 | memorySize: 512, 69 | environment: { } 70 | }); 71 | connectListInstancesLambda.role?.attachInlinePolicy(new iam.Policy(this, 'ConnectListInstancesAccess', { 72 | statements: [ 73 | new iam.PolicyStatement({ 74 | effect: iam.Effect.ALLOW, 75 | actions: ["connect:ListInstances"], 76 | resources: ['*'] 77 | }) 78 | ] 79 | })); 80 | connectListInstancesLambda.role?.attachInlinePolicy(describeDirectories); 81 | 82 | 83 | const connectShowInstanceLambda = new nodeLambda.NodejsFunction(this, 'connectShowInstanceLambda', { 84 | functionName: `${props.cdkAppName}-ConnectShowInstanceLambda`, 85 | runtime: lambda.Runtime.NODEJS_20_X, 86 | entry: 'lambdas/handlers/ConnectAPI/connectShowInstance.js', 87 | timeout: Duration.seconds(30), 88 | memorySize: 512, 89 | environment: { } 90 | }); 91 | connectShowInstanceLambda.role?.attachInlinePolicy(new iam.Policy(this, 'ConnectShowInstanceLambdaPolicy', { 92 | statements: [ 93 | new iam.PolicyStatement({ 94 | effect: iam.Effect.ALLOW, 95 | actions: ["connect:DescribeInstance"], 96 | resources: ['*'] 97 | }) 98 | ] 99 | })); 100 | connectShowInstanceLambda.role?.attachInlinePolicy(describeDirectories); 101 | 102 | const connectReplicateInstanceLambda = new nodeLambda.NodejsFunction(this, 'connectReplicateInstanceLambdaPolicy', { 103 | functionName: `${props.cdkAppName}-ConnectReplicateInstanceLambda`, 104 | runtime: lambda.Runtime.NODEJS_20_X, 105 | entry: 'lambdas/handlers/ConnectAPI/connectReplicateInstance.js', 106 | timeout: Duration.seconds(30), 107 | memorySize: 512, 108 | layers: [awsSdkLayer], 109 | environment: { } 110 | }); 111 | connectReplicateInstanceLambda.role?.attachInlinePolicy(new iam.Policy(this, 'ConnectReplicateInstanceLambdaPolicy', { 112 | statements: [ 113 | new iam.PolicyStatement({ 114 | effect: iam.Effect.ALLOW, 115 | actions: ["connect:ReplicateInstance", 116 | "connect:AssociateInstanceStorageConfig", 117 | "connect:CreateInstance", 118 | "connect:DeleteInstance", 119 | "connect:DescribeInstance", 120 | "connect:DescribeInstanceAttributes", 121 | "connect:DescribeInstanceStorageConfig", 122 | "connect:ListApprovedOrigins", 123 | "connect:ListInstances", 124 | "connect:ListInstanceStorageConfigs", 125 | "connect:ListSecurityKeys", 126 | "connect:UpdateInstanceAttribute", 127 | "ds:AuthorizeApplication", 128 | "ds:CheckAlias", 129 | "ds:CreateAlias", 130 | "ds:CreateDirectory", 131 | "ds:CreateIdentityPoolDirectory", 132 | "ds:DeleteDirectory", 133 | "ds:DescribeDirectories", 134 | "ds:UnauthorizeApplication", 135 | "iam:CreateServiceLinkedRole", 136 | "kms:CreateGrant", 137 | "kms:DescribeKey", 138 | "kms:ListAliases", 139 | "kms:RetireGrant", 140 | "logs:CreateLogGroup", 141 | "profile:GetDomain", 142 | "profile:GetProfileObjectType", 143 | "profile:ListAccountIntegrations", 144 | "profile:ListDomains", 145 | "profile:ListProfileObjectTypeTemplates", 146 | "s3:CreateBucket", 147 | "s3:GetBucketLocation", 148 | "s3:ListAllMyBuckets", 149 | "servicequotas:GetServiceQuota"], 150 | resources: ['*'] 151 | }) 152 | ] 153 | })); 154 | 155 | 156 | const connectCreateTrafficDistributionGroupLambda = new nodeLambda.NodejsFunction(this, 'connectCreateTrafficDistributionGroupLambda', { 157 | functionName: `${props.cdkAppName}-connectCreateTrafficDistributionGroupLambda`, 158 | runtime: lambda.Runtime.NODEJS_20_X, 159 | entry: 'lambdas/handlers/ConnectAPI/connectCreateTrafficDistributionGroup.js', 160 | timeout: Duration.seconds(30), 161 | memorySize: 512, 162 | layers: [awsSdkLayer], 163 | environment: { } 164 | }); 165 | connectCreateTrafficDistributionGroupLambda.role?.attachInlinePolicy(new iam.Policy(this, 'connectCreateTrafficDistributionGroupPolicy', { 166 | statements: [ 167 | new iam.PolicyStatement({ 168 | effect: iam.Effect.ALLOW, 169 | actions: ["connect:CreateTrafficDistributionGroup", 170 | "connect:TagResource"], 171 | resources: ['*'] 172 | }) 173 | ] 174 | })); 175 | 176 | 177 | const connectListTrafficDistributionGroupsLambda = new nodeLambda.NodejsFunction(this, 'connectListTrafficDistributionGroupsLambda', { 178 | functionName: `${props.cdkAppName}-connectListTrafficDistributionGroupsLambda`, 179 | runtime: lambda.Runtime.NODEJS_20_X, 180 | entry: 'lambdas/handlers/ConnectAPI/connectListTrafficDistributionGroups.js', 181 | timeout: Duration.seconds(30), 182 | memorySize: 512, 183 | layers: [awsSdkLayer], 184 | environment: { } 185 | }); 186 | connectListTrafficDistributionGroupsLambda.role?.attachInlinePolicy(new iam.Policy(this, 'connectListTrafficDistributionGroupsPolicy', { 187 | statements: [ 188 | new iam.PolicyStatement({ 189 | effect: iam.Effect.ALLOW, 190 | actions: ["connect:ListTrafficDistributionGroups"], 191 | resources: ['*'] 192 | }) 193 | ] 194 | })); 195 | 196 | const connectShowTrafficDistributionGroupLambda = new nodeLambda.NodejsFunction(this, 'connecShowTrafficDistributionGroupLambda', { 197 | functionName: `${props.cdkAppName}-connectShowTrafficDistributionGroupLambda`, 198 | runtime: lambda.Runtime.NODEJS_20_X, 199 | entry: 'lambdas/handlers/ConnectAPI/connectShowTrafficDistributionGroup.js', 200 | timeout: Duration.seconds(30), 201 | memorySize: 512, 202 | layers: [awsSdkLayer], 203 | environment: { } 204 | }); 205 | connectShowTrafficDistributionGroupLambda.role?.attachInlinePolicy(new iam.Policy(this, 'connectShowTrafficDistributionGroupPolicy', { 206 | statements: [ 207 | new iam.PolicyStatement({ 208 | effect: iam.Effect.ALLOW, 209 | actions: ["connect:DescribeTrafficDistributionGroup","connect:GetTrafficDistribution"], 210 | resources: ['*'] 211 | }) 212 | ] 213 | })); 214 | 215 | const connectUpdateTrafficDistributionLambda = new nodeLambda.NodejsFunction(this, 'connectUpdateTrafficDistribution', { 216 | functionName: `${props.cdkAppName}-connectUpdateTrafficDistribution`, 217 | runtime: lambda.Runtime.NODEJS_20_X, 218 | entry: 'lambdas/handlers/ConnectAPI/connectUpdateTrafficDistribution.js', 219 | timeout: Duration.seconds(30), 220 | memorySize: 512, 221 | layers: [awsSdkLayer], 222 | environment: { } 223 | }); 224 | connectUpdateTrafficDistributionLambda.role?.attachInlinePolicy(new iam.Policy(this, 'connectUpdateTrafficDistributionGroupsPolicy', { 225 | statements: [ 226 | new iam.PolicyStatement({ 227 | effect: iam.Effect.ALLOW, 228 | actions: ["connect:UpdateTrafficDistribution"], 229 | resources: ['*'] 230 | }) 231 | ] 232 | })); 233 | 234 | const connectListPhoneNumbersLambda = new nodeLambda.NodejsFunction(this, 'ConnectListPhoneNumbersLambda', { 235 | functionName: `${props.cdkAppName}-ConnectListPhoneNumbersLambda`, 236 | runtime: lambda.Runtime.NODEJS_20_X, 237 | entry: 'lambdas/handlers/ConnectAPI/connectListPhoneNumbers.js', 238 | layers: [awsSdkLayer], 239 | timeout: Duration.seconds(30), 240 | memorySize: 512, 241 | environment: { } 242 | }); 243 | connectListPhoneNumbersLambda.role?.attachInlinePolicy(new iam.Policy(this, 'ConnectListPhoneNumbersAccess', { 244 | statements: [ 245 | new iam.PolicyStatement({ 246 | effect: iam.Effect.ALLOW, 247 | actions: ["connect:ListPhoneNumbersV2"], 248 | resources: ['*'] 249 | }) 250 | ] 251 | })); 252 | 253 | const connectUpdatePhoneNumbersLambda = new nodeLambda.NodejsFunction(this, 'connectUpdatePhoneNumbers', { 254 | functionName: `${props.cdkAppName}-connectUpdatePhoneNumbers`, 255 | runtime: lambda.Runtime.NODEJS_20_X, 256 | entry: 'lambdas/handlers/ConnectAPI/connectUpdatePhoneNumbers.js', 257 | timeout: Duration.seconds(50), //Setting this a bit larger as we are going to loop through several updates 258 | memorySize: 512, 259 | layers: [awsSdkLayer], 260 | environment: { } 261 | }); 262 | connectUpdatePhoneNumbersLambda.role?.attachInlinePolicy(new iam.Policy(this, 'connectUpdatePhoneNumbersGroupsPolicy', { 263 | statements: [ 264 | new iam.PolicyStatement({ 265 | effect: iam.Effect.ALLOW, 266 | actions: ["connect:UpdatePhoneNumber"], 267 | resources: ['*'] 268 | }) 269 | ] 270 | })); 271 | 272 | const connectDeleteTrafficDistributionGroupLambda = new nodeLambda.NodejsFunction(this, 'connectDeleteTrafficDistributionGroup', { 273 | functionName: `${props.cdkAppName}-connectDeleteTrafficDistributionGroup`, 274 | runtime: lambda.Runtime.NODEJS_20_X, 275 | entry: 'lambdas/handlers/ConnectAPI/connectDeleteTrafficDistributionGroup.js', 276 | timeout: Duration.seconds(30), 277 | memorySize: 512, 278 | layers: [awsSdkLayer], 279 | environment: { } 280 | }); 281 | connectDeleteTrafficDistributionGroupLambda.role?.attachInlinePolicy(new iam.Policy(this, 'connectDeleteTrafficDistributionGroupGroupsPolicy', { 282 | statements: [ 283 | new iam.PolicyStatement({ 284 | effect: iam.Effect.ALLOW, 285 | actions: ["connect:DeleteTrafficDistributionGroup"], 286 | resources: ['*'] 287 | }) 288 | ] 289 | })); 290 | 291 | /************* create ConnectAPI Integration *********/ 292 | 293 | const connectAPI = new apigw2.HttpApi(this, 'ConnectAPI', { 294 | apiName: `${props.cdkAppName}-ConnectAPI`, 295 | corsPreflight: { 296 | allowOrigins: props.SSMParams.webappAPIAllowedOrigins.split(',').map((item: string) => item.trim()), 297 | allowMethods: [apigw2.CorsHttpMethod.GET, apigw2.CorsHttpMethod.POST, apigw2.CorsHttpMethod.PUT, apigw2.CorsHttpMethod.DELETE], 298 | allowHeaders: apigw.Cors.DEFAULT_HEADERS, 299 | } 300 | }); 301 | 302 | const connectAPIAuthorizer = new apigw2Authorizers.HttpUserPoolAuthorizer('ConnectAPIAuthorizer', props.cognitoUserPool, { 303 | userPoolClients: [props.cognitoUserPoolClient], 304 | }); 305 | 306 | // Setup the access log for APIGWv2 307 | const accessLogs = new logs.LogGroup(this, `${props.cdkAppName}-ConnectAPI-AccessLogs`) 308 | 309 | const stage = connectAPI.defaultStage?.node.defaultChild as CfnStage 310 | stage.accessLogSettings = { 311 | destinationArn: accessLogs.logGroupArn, 312 | format: JSON.stringify({ 313 | requestId: '$context.requestId', 314 | userAgent: '$context.identity.userAgent', 315 | sourceIp: '$context.identity.sourceIp', 316 | requestTime: '$context.requestTime', 317 | requestTimeEpoch: '$context.requestTimeEpoch', 318 | httpMethod: '$context.httpMethod', 319 | path: '$context.path', 320 | status: '$context.status', 321 | protocol: '$context.protocol', 322 | responseLength: '$context.responseLength', 323 | domainName: '$context.domainName' 324 | }) 325 | } 326 | 327 | const role = new iam.Role(this, 'ApiGWLogWriterRole', { 328 | assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com') 329 | }) 330 | 331 | const policy = new iam.PolicyStatement({ 332 | actions: [ 333 | 'logs:CreateLogGroup', 334 | 'logs:CreateLogStream', 335 | 'logs:DescribeLogGroups', 336 | 'logs:DescribeLogStreams', 337 | 'logs:PutLogEvents', 338 | 'logs:GetLogEvents', 339 | 'logs:FilterLogEvents' 340 | ], 341 | resources: ['*'] 342 | }) 343 | 344 | role.addToPolicy(policy) 345 | accessLogs.grantWrite(role) 346 | 347 | connectAPI.addRoutes({ 348 | integration: new apigw2Integrations.HttpLambdaIntegration('connectListInstancesAPI', connectListInstancesLambda), 349 | path: '/connectListInstances', 350 | authorizer: connectAPIAuthorizer, 351 | methods: [apigw2.HttpMethod.GET], 352 | }); 353 | 354 | connectAPI.addRoutes({ 355 | integration: new apigw2Integrations.HttpLambdaIntegration('connectShowInstanceAPI', connectShowInstanceLambda), 356 | path: '/connectShowInstance', 357 | authorizer: connectAPIAuthorizer, 358 | methods: [apigw2.HttpMethod.GET], 359 | }); 360 | 361 | connectAPI.addRoutes({ 362 | integration: new apigw2Integrations.HttpLambdaIntegration('connectReplicateInstanceAPI', connectReplicateInstanceLambda), 363 | path: '/connectReplicateInstance', 364 | authorizer: connectAPIAuthorizer, 365 | methods: [apigw2.HttpMethod.POST], 366 | }); 367 | connectAPI.addRoutes({ 368 | integration: new apigw2Integrations.HttpLambdaIntegration('connectCreateTrafficDistributionGroupAPI', connectCreateTrafficDistributionGroupLambda), 369 | path: '/connectCreateTrafficDistributionGroup', 370 | authorizer: connectAPIAuthorizer, 371 | methods: [apigw2.HttpMethod.PUT], 372 | }); 373 | connectAPI.addRoutes({ 374 | integration: new apigw2Integrations.HttpLambdaIntegration('connectListTrafficDistributionGroupsAPI', connectListTrafficDistributionGroupsLambda), 375 | path: '/connectListTrafficDistributionGroups', 376 | authorizer: connectAPIAuthorizer, 377 | methods: [apigw2.HttpMethod.GET], 378 | }); 379 | connectAPI.addRoutes({ 380 | integration: new apigw2Integrations.HttpLambdaIntegration('connectShowTrafficDistributionGroupAPI', connectShowTrafficDistributionGroupLambda), 381 | path: '/connectShowTrafficDistributionGroup', 382 | authorizer: connectAPIAuthorizer, 383 | methods: [apigw2.HttpMethod.GET], 384 | }); 385 | connectAPI.addRoutes({ 386 | integration: new apigw2Integrations.HttpLambdaIntegration('connectUpdateTrafficDistributionAPI', connectUpdateTrafficDistributionLambda), 387 | path: '/connectUpdateTrafficDistribution', 388 | authorizer: connectAPIAuthorizer, 389 | methods: [apigw2.HttpMethod.PUT], 390 | }); 391 | connectAPI.addRoutes({ 392 | integration: new apigw2Integrations.HttpLambdaIntegration('connectListPhoneNumbersAPI', connectListPhoneNumbersLambda), 393 | path: '/connectListPhoneNumbers', 394 | authorizer: connectAPIAuthorizer, 395 | methods: [apigw2.HttpMethod.POST], 396 | }); 397 | connectAPI.addRoutes({ 398 | integration: new apigw2Integrations.HttpLambdaIntegration('connectUpdatePhoneNumbersAPI', connectUpdatePhoneNumbersLambda), 399 | path: '/connectUpdatePhoneNumbers', 400 | authorizer: connectAPIAuthorizer, 401 | methods: [apigw2.HttpMethod.PUT], 402 | }); 403 | connectAPI.addRoutes({ 404 | integration: new apigw2Integrations.HttpLambdaIntegration('connectDeleteTrafficDistributionGroupAPI', connectDeleteTrafficDistributionGroupLambda), 405 | path: '/connectDeleteTrafficDistributionGroup', 406 | authorizer: connectAPIAuthorizer, 407 | methods: [apigw2.HttpMethod.DELETE], 408 | }); 409 | 410 | this.connectAPI = connectAPI; 411 | 412 | } 413 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/cdk-backend-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import {CfnOutput, RemovalPolicy, Stack, StackProps} from "aws-cdk-lib"; 5 | import {Construct} from "constructs"; 6 | 7 | import * as ssm from 'aws-cdk-lib/aws-ssm'; 8 | import * as s3 from "aws-cdk-lib/aws-s3"; 9 | import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; 10 | 11 | import { loadSSMParams } from '../lib/infrastructure/ssm-params-util'; 12 | 13 | import { CognitoStack } from '../lib/infrastructure/cognito-stack'; 14 | import { ConnectAPIStack } from '../lib/api/connectAPI-stack'; 15 | import { FrontendConfigStack } from '../lib/frontend/frontend-config-stack'; 16 | import { NagSuppressions } from 'cdk-nag' 17 | 18 | const configParams = require('../config.params.json'); 19 | 20 | export class CdkBackendStack extends Stack { 21 | 22 | public readonly webAppBucket: s3.IBucket; 23 | public readonly accessLogsBucket: s3.IBucket; 24 | public readonly webAppCloudFrontOAI: cloudfront.IOriginAccessIdentity; 25 | 26 | constructor(scope: Construct, id: string, props?: StackProps) { 27 | super(scope, id, props); 28 | 29 | //store physical stack name to SSM 30 | const outputHierarchy = `${configParams.hierarchy}outputParameters`; 31 | const cdkBackendStackName = new ssm.StringParameter(this, 'CdkBackendStackName', { 32 | parameterName: `${outputHierarchy}/CdkBackendStackName`, 33 | stringValue: this.stackName 34 | }); 35 | 36 | const ssmParams = loadSSMParams(this); 37 | 38 | // create infrastructure stacks 39 | 40 | const cognitoStack = new CognitoStack(this, 'CognitoStack', { 41 | SSMParams: ssmParams, 42 | cdkAppName: configParams['CdkAppName'] 43 | }); 44 | 45 | //create API stacks 46 | const connectAPIStack = new ConnectAPIStack(this, 'ConnectAPIStack', { 47 | SSMParams: ssmParams, 48 | cognitoUserPool: cognitoStack.userPool, 49 | cognitoUserPoolClient: cognitoStack.userPoolClient, 50 | cdkAppName: configParams['CdkAppName'], 51 | }); 52 | connectAPIStack.addDependency(cognitoStack); 53 | 54 | //create log bucket 55 | const accessLogsBucket = new s3.Bucket(this, "accessLogsBucket", { 56 | bucketName: `${configParams['CdkAppName']}-AccessLogsBucket-${this.account}-${this.region}`.toLowerCase(), 57 | removalPolicy: RemovalPolicy.DESTROY, 58 | encryption: s3.BucketEncryption.S3_MANAGED, 59 | enforceSSL: true, 60 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL 61 | }); 62 | 63 | NagSuppressions.addResourceSuppressions(accessLogsBucket, [ 64 | { 65 | id: 'AwsSolutions-S1', 66 | reason: 'This is the Log Bucket.' 67 | }, 68 | ]) 69 | 70 | //create webapp bucket 71 | const webAppBucket = new s3.Bucket(this, "WebAppBucket", { 72 | bucketName: `${configParams['CdkAppName']}-WebAppBucket-${this.account}-${this.region}`.toLowerCase(), 73 | removalPolicy: RemovalPolicy.DESTROY, 74 | encryption: s3.BucketEncryption.S3_MANAGED, 75 | enforceSSL: true, 76 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 77 | serverAccessLogsBucket: accessLogsBucket, 78 | serverAccessLogsPrefix: 'webapp', 79 | }); 80 | 81 | const webAppCloudFrontOAI = new cloudfront.OriginAccessIdentity(this, `${configParams['CdkAppName']}-WebAppOAI`,); 82 | 83 | //create frontend config 84 | const frontendConfigStack = new FrontendConfigStack(this, 'FrontendConfigStack', { 85 | cdkAppName: configParams['CdkAppName'], 86 | webAppBucket: webAppBucket, 87 | accessLogsBucket: accessLogsBucket, 88 | backendStackOutputs: [ 89 | { key: 'userPoolId', value: cognitoStack.userPool.userPoolId }, 90 | { key: 'userPoolWebClientId', value: cognitoStack.userPoolClient.userPoolClientId }, 91 | { key: 'cognitoDomainURL', value: `https://${cognitoStack.userPoolDomain.domain}.auth.${this.region}.amazoncognito.com` }, 92 | { key: 'connectAPI', value: `${connectAPIStack.connectAPI.apiEndpoint}/` }, 93 | { key: 'backendRegion', value: this.region }, 94 | { key: 'cognitoSAMLEnabled', value: String(ssmParams.cognitoSAMLEnabled) }, 95 | { key: 'cognitoSAMLIdentityProviderName', value: ssmParams.cognitoSAMLIdentityProviderName }, 96 | ] 97 | }); 98 | frontendConfigStack.addDependency(cognitoStack); 99 | frontendConfigStack.addDependency(connectAPIStack); 100 | 101 | 102 | /************************************************************************************************************** 103 | * CDK Outputs * 104 | **************************************************************************************************************/ 105 | 106 | this.webAppBucket = webAppBucket; 107 | this.accessLogsBucket = accessLogsBucket; 108 | this.webAppCloudFrontOAI = webAppCloudFrontOAI; 109 | 110 | new CfnOutput(this, "userPoolId", { 111 | value: cognitoStack.userPool.userPoolId 112 | }); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /cdk-stacks/lib/cdk-frontend-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { Stack, StackProps, CfnOutput, Duration} from "aws-cdk-lib"; 5 | import {Construct} from "constructs"; 6 | import { NagSuppressions } from 'cdk-nag' 7 | 8 | import * as ssm from 'aws-cdk-lib/aws-ssm' 9 | import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; 10 | import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; 11 | import * as s3 from "aws-cdk-lib/aws-s3"; 12 | 13 | import { FrontendS3DeploymentStack } from '../lib/frontend/frontend-s3-deployment-stack'; 14 | 15 | const configParams = require('../config.params.json') 16 | export interface CdkFrontendStackProps extends StackProps { 17 | readonly webAppBucket: s3.IBucket; 18 | readonly accessLogsBucket: s3.IBucket; 19 | readonly webAppCloudFrontOAI: cloudfront.IOriginAccessIdentity; 20 | } 21 | 22 | export class CdkFrontendStack extends Stack { 23 | constructor(scope: Construct, id: string, props: CdkFrontendStackProps) { 24 | super(scope, id, props); 25 | 26 | //store physical stack name to SSM 27 | const outputHierarchy = `${configParams.hierarchy}outputParameters`; 28 | const cdkFrontendStackName = new ssm.StringParameter(this, 'CdkFrontendStackName', { 29 | parameterName: `${outputHierarchy}/CdkFrontendStackName`, 30 | stringValue: this.stackName 31 | }); 32 | 33 | const frontendS3DeploymentStack = new FrontendS3DeploymentStack(this, 'FrontendS3DeploymentStack', { 34 | cdkAppName: configParams['CdkAppName'], 35 | webAppBucket: props.webAppBucket, 36 | accessLogsBucket: props.accessLogsBucket 37 | }); 38 | 39 | const webAppCloudFrontDistribution = new cloudfront.CloudFrontWebDistribution(this, `${configParams['CdkAppName']}-WebAppDistribution`, { 40 | originConfigs: [ 41 | { 42 | s3OriginSource: { 43 | s3BucketSource: props.webAppBucket, 44 | originPath: `/${configParams['WebAppRootPrefix'].replace(/\/$/, "")}`, 45 | originAccessIdentity: props.webAppCloudFrontOAI, 46 | }, 47 | behaviors: [ 48 | { 49 | defaultTtl: Duration.minutes(1), 50 | isDefaultBehavior: true, 51 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 52 | }, 53 | ] 54 | } 55 | ], 56 | errorConfigurations: [{ 57 | errorCode: 403, 58 | errorCachingMinTtl: 60, 59 | responsePagePath: '/index.html', 60 | responseCode: 200 61 | }], 62 | loggingConfig: { 63 | bucket: props.accessLogsBucket, 64 | includeCookies: false, 65 | prefix: 'cfaccesslogs', 66 | } 67 | }); 68 | 69 | //Adding HTTP Security Headers 70 | const cfnDistribution = webAppCloudFrontDistribution.node.defaultChild as cloudfront.CfnDistribution; 71 | 72 | cfnDistribution.addPropertyOverride( 73 | 'DistributionConfig.DefaultCacheBehavior.ResponseHeadersPolicyId', 74 | cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS.responseHeadersPolicyId 75 | ); 76 | 77 | NagSuppressions.addResourceSuppressions(webAppCloudFrontDistribution, [ 78 | { 79 | id: 'AwsSolutions-CFR4', 80 | reason: 'Using CloudFront Provided Cert which defaults this to TLS1. Hoping to avoid customer needing to provision cert just to deploy solution.' 81 | }, 82 | ]) 83 | 84 | /************************************************************************************************************** 85 | * CDK Outputs * 86 | **************************************************************************************************************/ 87 | 88 | new CfnOutput(this, "webAppBucket", { 89 | value: props.webAppBucket.bucketName 90 | }); 91 | 92 | new CfnOutput(this, "accessLogBucket", { 93 | value: props.accessLogsBucket.bucketName 94 | }); 95 | 96 | new CfnOutput(this, "webAppURL", { 97 | value: `https://${webAppCloudFrontDistribution.distributionDomainName}` 98 | }); 99 | } 100 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/frontend/frontend-config-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { NestedStack, NestedStackProps, Duration, CustomResource } from 'aws-cdk-lib'; 5 | import {Construct} from "constructs"; 6 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 7 | import * as iam from 'aws-cdk-lib/aws-iam'; 8 | import * as s3 from "aws-cdk-lib/aws-s3"; 9 | import { NagSuppressions } from 'cdk-nag' 10 | 11 | const configParams = require('../../config.params.json'); 12 | 13 | export interface FrontendConfigStackProps extends NestedStackProps { 14 | readonly backendStackOutputs: { key: string, value: string }[]; 15 | readonly webAppBucket: s3.IBucket; 16 | readonly accessLogsBucket: s3.IBucket; 17 | readonly cdkAppName: string; 18 | } 19 | 20 | export class FrontendConfigStack extends NestedStack { 21 | 22 | constructor(scope: Construct, id: string, props: FrontendConfigStackProps) { 23 | super(scope, id, props); 24 | 25 | NagSuppressions.addStackSuppressions(this, [ 26 | { 27 | id: 'AwsSolutions-IAM4', 28 | reason: 'This is the default Lambda Execution Policy for a CDK Deployment Lambda which just grants writes to CloudWatch.' 29 | }, 30 | ]) 31 | 32 | const buildConfigParameters = () => { 33 | const result: any = {}; 34 | props.backendStackOutputs.forEach(param => { 35 | result[param.key] = param.value; 36 | }); 37 | return JSON.stringify(result); 38 | } 39 | 40 | // frontend config custom resource 41 | const frontendConfigLambda = new lambda.Function(this, `FrontendConfigLambda`, { 42 | functionName: `${props.cdkAppName}-FrontendConfigLambda`, 43 | runtime: lambda.Runtime.PYTHON_3_9, 44 | code: lambda.Code.fromAsset('lambdas/custom-resources/frontend-config'), 45 | handler: 'index.handler', 46 | timeout: Duration.seconds(120), 47 | initialPolicy: [new iam.PolicyStatement({ 48 | effect: iam.Effect.ALLOW, 49 | actions: ["s3:PutObject", "s3:DeleteObject"], 50 | resources: [ 51 | `${props.webAppBucket.bucketArn}/${configParams['WebAppStagingPrefix']}frontend-config.zip`, 52 | `${props.webAppBucket.bucketArn}/${configParams['WebAppRootPrefix']}frontend-config.js` 53 | ] 54 | })] 55 | }); 56 | 57 | const frontendConfigCustomResource = new CustomResource(this, `${props.cdkAppName}-FrontendConfigCustomResource`, { 58 | resourceType: 'Custom::FrontendConfig', 59 | serviceToken: frontendConfigLambda.functionArn, 60 | properties: { 61 | BucketName: props.webAppBucket.bucketName, 62 | WebAppStagingObjectPrefix: configParams['WebAppStagingPrefix'], 63 | WebAppRootObjectPrefix: configParams['WebAppRootPrefix'], 64 | ObjectKey: `frontend-config.js`, 65 | ContentType: 'text/javascript', 66 | Content: `window.webappConfig = ${buildConfigParameters()}` 67 | } 68 | }); 69 | } 70 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/frontend/frontend-s3-deployment-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import {NestedStack, NestedStackProps} from "aws-cdk-lib"; 5 | import {Construct} from "constructs"; 6 | import { NagSuppressions } from 'cdk-nag' 7 | 8 | import * as s3deployment from "aws-cdk-lib/aws-s3-deployment"; 9 | import * as s3 from "aws-cdk-lib/aws-s3"; 10 | 11 | const configParams = require('../../config.params.json'); 12 | 13 | export interface FrontendS3DeploymentStackProps extends NestedStackProps { 14 | readonly cdkAppName: string; 15 | readonly webAppBucket: s3.IBucket; 16 | readonly accessLogsBucket: s3.IBucket; 17 | } 18 | 19 | export class FrontendS3DeploymentStack extends NestedStack { 20 | 21 | public readonly webAppBucket: s3.IBucket; 22 | public readonly accessLogsBucket: s3.IBucket; 23 | 24 | constructor(scope: Construct, id: string, props: FrontendS3DeploymentStackProps) { 25 | super(scope, id, props); 26 | 27 | NagSuppressions.addStackSuppressions(this, [ 28 | { 29 | id: 'AwsSolutions-IAM4', 30 | reason: 'This is the CDK Deployment Bucket.' 31 | }, 32 | { 33 | id: 'AwsSolutions-IAM5', 34 | reason: 'This is the CDK Deployment Bucket.' 35 | }, 36 | ]) 37 | 38 | const webAppDeployment = new s3deployment.BucketDeployment(this, `${props.cdkAppName}-WebAppDeployment`, { 39 | destinationBucket: props.webAppBucket, 40 | retainOnDelete: false, 41 | destinationKeyPrefix: configParams['WebAppRootPrefix'], 42 | sources: [ 43 | s3deployment.Source.asset('../webapp/build'), 44 | s3deployment.Source.bucket(props.webAppBucket, `${configParams['WebAppStagingPrefix']}frontend-config.zip`) 45 | ] 46 | }); 47 | } 48 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/infrastructure/cognito-stack.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { NestedStack, NestedStackProps, RemovalPolicy, Duration } from 'aws-cdk-lib' 5 | import { Construct } from 'constructs'; 6 | import * as cognito from 'aws-cdk-lib/aws-cognito' 7 | import * as iam from 'aws-cdk-lib/aws-iam'; 8 | 9 | export interface CognitoStackProps extends NestedStackProps { 10 | readonly SSMParams: any; 11 | readonly cdkAppName: string; 12 | } 13 | 14 | export class CognitoStack extends NestedStack { 15 | 16 | public readonly userPool: cognito.IUserPool; 17 | public readonly userPoolClient: cognito.IUserPoolClient; 18 | public readonly userPoolDomain: cognito.CfnUserPoolDomain; 19 | 20 | constructor(scope: Construct, id: string, props: CognitoStackProps) { 21 | super(scope, id, props); 22 | 23 | //create a User Pool 24 | const userPool = new cognito.UserPool(this, 'UserPool', { 25 | userPoolName: `${props.cdkAppName}-UserPool`, 26 | removalPolicy: RemovalPolicy.DESTROY, 27 | passwordPolicy:{ 28 | minLength: 8, 29 | requireLowercase: true, 30 | requireUppercase: true, 31 | requireDigits: true, 32 | requireSymbols: true, 33 | tempPasswordValidity: Duration.days(7), 34 | }, 35 | signInAliases: { 36 | username: false, 37 | phone: false, 38 | email: true 39 | }, 40 | standardAttributes: { 41 | email: { 42 | required: false, //Cognito bug with federation - If you make a user pool with required email field then the second google login attempt fails (https://github.com/aws-amplify/amplify-js/issues/3526) 43 | mutable: true 44 | } 45 | }, 46 | userInvitation: { 47 | emailSubject: "Your Amazon Connect Global Resiliency dashboard temporary password", 48 | emailBody: "Your Amazon Connect Global Resiliency dashboard username is {username} and temporary password is {####}" 49 | }, 50 | userVerification: { 51 | emailSubject: "Verify your new Amazon Connect Global Resiliency dashboard account", 52 | emailBody: "The verification code to your new Amazon Connect Global Resiliency management dashboard is {####}" 53 | } 54 | }); 55 | 56 | // any properties that are not part of the high level construct can be added using this method 57 | const userPoolCfn = userPool.node.defaultChild as cognito.CfnUserPool; 58 | userPoolCfn.userPoolAddOns = { advancedSecurityMode: "ENFORCED" }; 59 | 60 | //SAML Federation 61 | let cognitoSAML: cognito.CfnUserPoolIdentityProvider | undefined = undefined; 62 | let supportedIdentityProviders: cognito.UserPoolClientIdentityProvider[] = []; 63 | let userPoolClientOAuthConfig: cognito.OAuthSettings = { 64 | scopes: [cognito.OAuthScope.EMAIL, cognito.OAuthScope.OPENID, cognito.OAuthScope.COGNITO_ADMIN, cognito.OAuthScope.PROFILE] 65 | } 66 | if (props.SSMParams.cognitoSAMLEnabled) { 67 | cognitoSAML = new cognito.CfnUserPoolIdentityProvider(this, "CognitoSAML", { 68 | providerName: props.SSMParams.cognitoSAMLIdentityProviderName, 69 | providerType: 'SAML', 70 | providerDetails: { 71 | MetadataURL: props.SSMParams.cognitoSAMLIdentityProviderURL 72 | }, 73 | attributeMapping: { 74 | "email": "email", 75 | "email_verified": "email_verified", 76 | "name": "name" 77 | }, 78 | userPoolId: userPool.userPoolId 79 | }) 80 | supportedIdentityProviders.push(cognito.UserPoolClientIdentityProvider.custom(cognitoSAML.providerName)); 81 | userPoolClientOAuthConfig = { 82 | ...userPoolClientOAuthConfig, 83 | callbackUrls: props.SSMParams.cognitoSAMLCallbackUrls.split(',').map((item: string) => item.trim()), 84 | logoutUrls: props.SSMParams.cognitoSAMLLogoutUrls.split(',').map((item: string) => item.trim()) 85 | } 86 | } 87 | 88 | //create a User Pool Client 89 | const userPoolClient = new cognito.UserPoolClient(this, 'UserPoolClient', { 90 | userPool: userPool, 91 | userPoolClientName: 'webappClient', 92 | generateSecret: false, 93 | refreshTokenValidity: Duration.hours(72), 94 | supportedIdentityProviders: supportedIdentityProviders, 95 | oAuth: userPoolClientOAuthConfig 96 | }); 97 | 98 | if (cognitoSAML) { 99 | userPoolClient.node.addDependency(cognitoSAML); 100 | } 101 | 102 | const userPoolDomain = new cognito.CfnUserPoolDomain(this, "UserPoolDomain", { 103 | domain: props.SSMParams.cognitoDomainPrefix, 104 | userPoolId: userPool.userPoolId 105 | }); 106 | 107 | /************************************************************************************************************** 108 | * Stack Outputs * 109 | **************************************************************************************************************/ 110 | 111 | this.userPool = userPool; 112 | this.userPoolClient = userPoolClient; 113 | this.userPoolDomain = userPoolDomain; 114 | } 115 | } -------------------------------------------------------------------------------- /cdk-stacks/lib/infrastructure/ssm-params-util.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import {Construct} from "constructs"; 5 | const configParams = require('../../config.params.json') 6 | import * as ssm from 'aws-cdk-lib/aws-ssm' 7 | 8 | export const loadSSMParams = (scope: Construct) => { 9 | const params: any = {} 10 | const SSM_NOT_DEFINED = 'not-defined'; 11 | for (const param of configParams.parameters) { 12 | if (param.boolean) { 13 | params[param.name] = (ssm.StringParameter.valueFromLookup(scope, `${configParams.hierarchy}${param.name}`).toLowerCase() === "true"); 14 | } 15 | else { 16 | params[param.name] = ssm.StringParameter.valueFromLookup(scope, `${configParams.hierarchy}${param.name}`); 17 | } 18 | } 19 | return { ...params, SSM_NOT_DEFINED } 20 | } 21 | 22 | export const fixDummyValueString = (value: string): string => { 23 | if (value.includes('dummy-value-for-')) return value.replace(/\//g, '-').replace('dummy-value-for-',''); 24 | else return value; 25 | } -------------------------------------------------------------------------------- /cdk-stacks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-stacks", 3 | "version": "1.0.5", 4 | "description": "Amazon Connect Global Resiliency starter project", 5 | "main": "configure.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "watch": "tsc -w", 9 | "test": "jest", 10 | "cdk": "cdk", 11 | "configure": "node configure.js -il", 12 | "configure:test": "node configure.js -ilt", 13 | "install:webapp": "cd ../webapp && npm install", 14 | "install:cdk-stacks": "npm install", 15 | "install:lambda-layer": "cd lambda-layer/nodejs && npm install", 16 | "install:lambdas": "cd lambdas && npm install", 17 | "install:all": "npm run install:webapp && npm run install:cdk-stacks && npm run install:lambdas && npm run install:lambda-layer", 18 | "echo:web-app-root-prefix": "node -e 'var config=require(`./config.params.json`); console.log(`${config.WebAppRootPrefix}`)' ", 19 | "echo:cdk-frontend-stack-name-param": "node -e 'var config=require(`./config.params.json`); console.log(`${config.hierarchy}outputParameters/CdkFrontendStackName`)' ", 20 | "echo:cdk-frontend-stack-physical-name": "aws ssm get-parameter --query 'Parameter'.'Value' --name $(npm run --silent echo:cdk-frontend-stack-name-param) --output text", 21 | "echo:web-app-bucket": "aws cloudformation describe-stacks --stack-name $(npm run --silent echo:cdk-frontend-stack-physical-name) --query 'Stacks[0].Outputs[?OutputKey==`webAppBucket`].OutputValue' --output text", 22 | "sync-config:webapp": "aws s3 cp s3://$(npm run --silent echo:web-app-bucket)/$(npm run --silent echo:web-app-root-prefix)frontend-config.js ../webapp/", 23 | "sync-config": "npm run --silent sync-config:webapp", 24 | "build:webapp": "cd ../webapp && npm run-script build", 25 | "build:webapp:gitbash": "cd ../webapp && npm run-script build:gitbash", 26 | "build:frontend": "npm run build:webapp", 27 | "build:frontend:gitbash": "npm run build:webapp:gitbash", 28 | "cdk:remove:context": "rm -f cdk.context.json", 29 | "cdk:deploy": "npm run cdk:remove:context && cdk deploy --all --disable-rollback", 30 | "cdk:deploy:gitbash": "npm run cdk:remove:context && winpty cdk.cmd deploy --all --disable-rollback", 31 | "build:deploy:all": "npm run build:frontend && npm run cdk:deploy", 32 | "build:deploy:all:gitbash": "npm run build:frontend:gitbash && npm run cdk:deploy:gitbash" 33 | }, 34 | "author": "prod-apps-builder-team", 35 | "license": "MIT-0", 36 | "devDependencies": { 37 | "@aws-sdk/client-ssm": "^3.170.0", 38 | "@types/node": "^18.7.16", 39 | "aws-cdk": "^2.128.0", 40 | "esbuild": "^0.15.7", 41 | "jest": "^29.0.3", 42 | "ts-jest": "^29.0.1", 43 | "ts-node": "^10.9.1", 44 | "typescript": "^4.8.3" 45 | }, 46 | "dependencies": { 47 | "@aws-cdk/aws-apigatewayv2-alpha": "^2.41.0-alpha.0", 48 | "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "^2.41.0-alpha.0", 49 | "@aws-cdk/aws-apigatewayv2-integrations-alpha": "^2.41.0-alpha.0", 50 | "aws-cdk-lib": "^2.41.0", 51 | "cdk-nag": "^2.18.43", 52 | "constructs": "^10.1.102", 53 | "source-map-support": "^0.5.21" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cdk-stacks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"] 21 | }, 22 | "exclude": ["cdk.out"] 23 | } 24 | -------------------------------------------------------------------------------- /documentation/diagrams/GlobalResiliency-Architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/documentation/diagrams/GlobalResiliency-Architecture.png -------------------------------------------------------------------------------- /documentation/images/instance-overview-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/documentation/images/instance-overview-screen.png -------------------------------------------------------------------------------- /webapp/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | module.exports = { 5 | 'env': { 6 | 'browser': true, 7 | 'es2021': true 8 | }, 9 | 'extends': [ 10 | 'eslint:recommended', 11 | 'plugin:react/recommended' 12 | ], 13 | 'parserOptions': { 14 | 'ecmaFeatures': { 15 | 'jsx': true 16 | }, 17 | 'ecmaVersion': 'latest', 18 | 'sourceType': 'module' 19 | }, 20 | 'plugins': [ 21 | 'react' 22 | ], 23 | 'rules': { 24 | 'indent': [ 25 | 'error', 26 | 2 27 | ], 28 | 'linebreak-style': [ 29 | 'error', 30 | 'unix' 31 | ], 32 | 'quotes': [ 33 | 'error', 34 | 'single' 35 | ], 36 | 'semi': [ 37 | 'error', 38 | 'never' 39 | ], 40 | 'no-var': [ 41 | 'error' 42 | ], 43 | 'no-unused-vars': [ 44 | 'warn' 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /webapp/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 12, 9 | "sourceType": "module", 10 | "requireConfigFile": false 11 | }, 12 | "parser": "@babel/eslint-parser", 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties" 15 | ], 16 | "rules": { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | #ignore frontend-config.js, as it's downloaded from S3 9 | frontend-config.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | /dist 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | .eslintcache 25 | /.vscode 26 | .vscode 27 | 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # General 33 | .DS_Store 34 | .AppleDouble 35 | .LSOverride 36 | 37 | # Icon must end with two \r 38 | Icon 39 | 40 | 41 | # Thumbnails 42 | ._* 43 | 44 | # Files that might appear in the root of a volume 45 | .DocumentRevisions-V100 46 | .fseventsd 47 | .Spotlight-V100 48 | .TemporaryItems 49 | .Trashes 50 | .VolumeIcon.icns 51 | .com.apple.timemachine.donotpresent 52 | 53 | # Directories potentially created on remote AFP share 54 | .AppleDB 55 | .AppleDesktop 56 | Network Trash Folder 57 | Temporary Items 58 | .apdisk -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "version": "1.0.5", 4 | "description": "Amazon Connect Global Resiliency starter project", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack --config=webpack.config.prod.js --mode=production", 8 | "build:gitbash": "set NODE_ENV=production&&webpack --config=webpack.config.prod.js --mode=production", 9 | "start": "NODE_ENV=development webpack serve --https --config=webpack.config.dev.js --mode=development --port 3001" 10 | }, 11 | "author": "prod-apps-builder-team", 12 | "license": "MIT-0", 13 | "babel": { 14 | "presets": [ 15 | "@babel/preset-env", 16 | "@babel/preset-react" 17 | ], 18 | "plugins": [ 19 | [ 20 | "@babel/transform-runtime" 21 | ] 22 | ] 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.19.0", 26 | "@babel/plugin-transform-runtime": "^7.18.10", 27 | "@babel/preset-env": "^7.19.0", 28 | "@babel/preset-react": "^7.18.6", 29 | "babel-loader": "^8.2.5", 30 | "clean-webpack-plugin": "^4.0.0", 31 | "crypto-browserify": "^3.12.0", 32 | "css-loader": "^6.7.1", 33 | "eslint": "^8.25.0", 34 | "eslint-config-standard": "^17.0.0", 35 | "eslint-plugin-import": "^2.26.0", 36 | "eslint-plugin-n": "^15.3.0", 37 | "eslint-plugin-promise": "^6.1.1", 38 | "eslint-plugin-react": "^7.31.10", 39 | "html-webpack-plugin": "^5.5.0", 40 | "process": "^0.11.10", 41 | "stream-browserify": "^3.0.0", 42 | "style-loader": "^3.3.1", 43 | "webpack": "^5.76.0", 44 | "webpack-cli": "^4.10.0", 45 | "webpack-dev-server": "^4.11.0", 46 | "webpack-merge": "^5.8.0" 47 | }, 48 | "dependencies": { 49 | "@aws-amplify/api-rest": "^2.0.29", 50 | "@aws-amplify/auth": "^4.3.19", 51 | "@aws-amplify/ui-components": "^1.9.6", 52 | "@aws-amplify/ui-react": "^1.2.26", 53 | "@babel/runtime": "^7.19.0", 54 | "aws-amplify": "^4.3.35", 55 | "aws-northstar": "^1.3.20", 56 | "react": "^17.0.2", 57 | "react-dom": "^17.0.2", 58 | "react-router-dom": "^5.3.3" 59 | }, 60 | "overrides": { 61 | "d3-color": "^3.1.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /webapp/src/App.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | */ 5 | 6 | .App { 7 | #text-align: center; 8 | } 9 | 10 | td { 11 | font-size: 13px !important 12 | } 13 | 14 | body { 15 | font-size: 14px !important 16 | } -------------------------------------------------------------------------------- /webapp/src/App.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useEffect, useState, useRef } from 'react' 5 | import { BrowserRouter as Router, Route } from 'react-router-dom' 6 | 7 | import { AmplifyAuthenticator, AmplifySignIn } from '@aws-amplify/ui-react' 8 | import { AuthState, onAuthUIStateChange } from '@aws-amplify/ui-components' 9 | import { Auth } from '@aws-amplify/auth' 10 | import { Badge } from 'aws-northstar' 11 | import Box from 'aws-northstar/layouts/Box' 12 | import BreadcrumbGroup from 'aws-northstar/components/BreadcrumbGroup' 13 | import Header from 'aws-northstar/components/Header' 14 | import Inline from 'aws-northstar/layouts/Inline' 15 | import SignoutButton from './components/SignoutButton' 16 | 17 | import './App.css' 18 | import Instance from './views/Instance' 19 | import InstanceList from './views/InstanceList' 20 | import LogoImage from './images/dashboard-logo-light.png' 21 | import ManageNumbers from './views/ManageNumbers' 22 | import Notifications from './components/Notifications' 23 | import TrafficDistributionGroup from './views/TrafficDistributionGroup' 24 | import { useAppConfig } from './providers/AppConfigProvider' 25 | import { useAppState } from './providers/AppStateProvider' 26 | import routes from './constants/routes' 27 | 28 | function App({ isFederateLogin, isFederateLogout }) { 29 | const { authState, setAuthState, setCognitoUser } = useAppState() 30 | const { cognitoSAMLIdentityProviderName, backendRegion } = useAppConfig() 31 | const prevAuthState = useRef() 32 | 33 | const [greetingName, setGreetingName] = useState('') 34 | const [loaded, setLoaded] = useState(false) 35 | 36 | const pathname = window.location.pathname 37 | 38 | useEffect(() => { 39 | if (isFederateLogin) { 40 | Auth.federatedSignIn({ provider: cognitoSAMLIdentityProviderName }) //automatically init signIn 41 | } 42 | else if (isFederateLogout) { 43 | window.location.href = `${window.location.protocol}//${window.location.host}` //back to root 44 | } 45 | 46 | return onAuthUIStateChange((nextAuthState, authData) => { 47 | console.debug(`Auth onAuthUIStateChange >> current is ${prevAuthState.current} while nextAuthState is ${nextAuthState}`) 48 | if (prevAuthState.current !== nextAuthState) { 49 | prevAuthState.current = nextAuthState 50 | handleNextAuthState(nextAuthState) 51 | } 52 | }) 53 | }, []) 54 | 55 | const handleNextAuthState = (nextAuthState) => { 56 | setAuthState(nextAuthState) 57 | if (nextAuthState === AuthState.SignedIn) { 58 | 59 | const postLoginRedirectURL = getPostLoginRedirectURL() 60 | if (postLoginRedirectURL && postLoginRedirectURL !== window.location.href) { 61 | window.location.href = postLoginRedirectURL 62 | } 63 | 64 | Auth.currentAuthenticatedUser().then(currentUser => { 65 | const currentUser_Name = currentUser.attributes?.name ? currentUser.attributes?.name : (currentUser.attributes?.email ? currentUser.attributes.email : currentUser.username) 66 | const currentUser_Username = currentUser.attributes?.email ? currentUser.attributes.email : currentUser.username 67 | 68 | setGreetingName(currentUser_Name) 69 | setCognitoUser(currentUser_Username, currentUser_Name) 70 | 71 | setLoaded(true) 72 | }).catch(error => { 73 | console.log('Error ', error) 74 | }) 75 | } 76 | if (nextAuthState === AuthState.SignIn) { 77 | setPostLoginRedirectURL(window.location.href) 78 | } 79 | if (nextAuthState === AuthState.SignedOut) { 80 | setTimeout(() => { 81 | console.debug('Logout refresh') 82 | window.location = window.location.pathname 83 | }, 500) 84 | } 85 | } 86 | 87 | const setPostLoginRedirectURL = (postLoginRedirectURL) => { 88 | window.sessionStorage.setItem('postLoginRedirectURL', postLoginRedirectURL) 89 | } 90 | 91 | const getPostLoginRedirectURL = () => { 92 | const postLoginRedirectURL = window.sessionStorage.getItem('postLoginRedirectURL') 93 | window.sessionStorage.removeItem('postLoginRedirectURL') 94 | return postLoginRedirectURL 95 | } 96 | 97 | return ( 98 | //check if authenticated 99 | authState === AuthState.SignedIn ? ( 100 |
101 |
}/> 102 | 103 | 104 | 105 | {pathname !== '/' && pathname !== '' && 106 | 115 | } 116 | 117 | 118 | < Route exact path="/" component={InstanceList} /> 119 | < Route exact path={`${routes.INSTANCE}/:instanceId?`} component={Instance} /> 120 | < Route exact path={`${routes.TRAFFIC_DISTRIBUTION_GROUP}/:tdgId?`} component={TrafficDistributionGroup} /> 121 | < Route exact path={`${routes.MANAGE_PHONE_NUMBERS}`} component={ManageNumbers} /> 122 | 123 |
124 | ) : ( 125 | 126 | 127 | 128 | 129 | 130 | 131 | ) 132 | ) 133 | } 134 | 135 | export default App -------------------------------------------------------------------------------- /webapp/src/apis/connectAPI.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { RestAPI } from '@aws-amplify/api-rest' 5 | import { Auth } from '@aws-amplify/auth' 6 | 7 | export const connectListInstances = async () => { 8 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 9 | console.log(cid) 10 | const response = await RestAPI.get('connectAPI', '/connectListInstances', { 11 | headers: { 12 | Authorization: cid, 13 | }, 14 | }) 15 | .catch(error => { 16 | if(error.message === 'Network Error'){ 17 | console.error('Connect List Instances >>', error) 18 | throw new Error('There was a network error. If the issue is CORS policy, check your API Gateway allowed origins.') 19 | } 20 | else { 21 | console.error('Connect List Instances >>', error.response) 22 | throw new Error(`${error.response.data.message}`) 23 | } 24 | }) 25 | 26 | //Add formatted date as additional attribute 27 | try { 28 | response.data.map(item => { 29 | if(item.CreatedTime) { 30 | item.Date = (new Date(item.CreatedTime).toLocaleString()) 31 | } }) 32 | } 33 | catch (error) { 34 | console.debug('Issue formatting creation date') 35 | //continue, date will be missing on table, but don't block the API response because of that 36 | } 37 | 38 | return response.data 39 | } 40 | 41 | export const connectShowInstance = async (instanceId) => { 42 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 43 | console.log(cid) 44 | const response = await RestAPI.get('connectAPI', `/connectShowInstance?instanceId=${instanceId}`, { 45 | headers: { 46 | Authorization: cid, 47 | } 48 | }) 49 | .catch(error => { 50 | console.error('Connect Show Instance >>', error.response) 51 | throw new Error(`${error.response.data.message}`) 52 | }) 53 | return response.data 54 | } 55 | 56 | 57 | export const connectReplicateInstance = async (instanceId, replicaAlias) => { 58 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 59 | console.log(cid) 60 | const response = await RestAPI.post('connectAPI', '/connectReplicateInstance', { 61 | headers: { 62 | Authorization: cid, 63 | }, 64 | body: { 65 | instanceId, 66 | replicaAlias 67 | } 68 | }) 69 | .catch(error => { 70 | console.error('Connect Replicate Instance >>', error.response) 71 | throw new Error(`${error.response.data.message}`) 72 | }) 73 | return response.data 74 | } 75 | 76 | 77 | export const connectCreateTrafficDistributionGroup = async (name, description, instanceId) => { 78 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 79 | console.log(cid) 80 | const response = await RestAPI.put('connectAPI', '/connectCreateTrafficDistributionGroup', { 81 | headers: { 82 | Authorization: cid, 83 | }, 84 | body: { 85 | 'Name': name, 86 | 'Description': description, 87 | 'InstanceId': instanceId 88 | } 89 | }) 90 | .catch(error => { 91 | console.error('Connect Create Traffic Distribution Group >>', error.response) 92 | throw new Error(`${error.response.data.message}`) 93 | }) 94 | return response.data 95 | } 96 | 97 | 98 | export const connectListTrafficDistributionGroups = async (instanceId) => { 99 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 100 | console.log(cid) 101 | const response = await RestAPI.get('connectAPI', `/connectListTrafficDistributionGroups?instanceId=${instanceId}`, { 102 | headers: { 103 | Authorization: cid, 104 | } 105 | }) 106 | .catch(error => { 107 | console.error('Connect List Traffic Distribution Groups >>', error.response) 108 | throw new Error(`${error.response.data.message}`) 109 | }) 110 | return response.data 111 | } 112 | 113 | export const connectShowTrafficDistributionGroup = async (trafficDistributionGroupId) => { 114 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 115 | console.log(cid) 116 | const response = await RestAPI.get('connectAPI', `/connectShowTrafficDistributionGroup?trafficDistributionGroupId=${trafficDistributionGroupId}`, { 117 | headers: { 118 | Authorization: cid, 119 | } 120 | }) 121 | .catch(error => { 122 | console.error('Connect Show Traffic Distribution Group >>', error.response) 123 | throw new Error(`${error.response.data.message}`) 124 | }) 125 | return response.data 126 | } 127 | 128 | export const connectUpdateTrafficDistribution = async (trafficDistribution) => { 129 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 130 | console.log(cid) 131 | const response = await RestAPI.put('connectAPI', '/connectUpdateTrafficDistribution', { 132 | headers: { 133 | Authorization: cid, 134 | }, 135 | body: trafficDistribution 136 | }) 137 | .catch(error => { 138 | console.error('Connect Update Traffic Distribution >>', error.response) 139 | throw new Error(`${error.response.data.message}`) 140 | }) 141 | return response.data 142 | } 143 | 144 | export const connectListPhoneNumbers = async (listPhoneNumberParams) => { 145 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 146 | console.log(cid) 147 | const response = await RestAPI.post('connectAPI', '/connectListPhoneNumbers', { 148 | headers: { 149 | Authorization: cid, 150 | }, 151 | body: listPhoneNumberParams 152 | }) 153 | .catch(error => { 154 | console.error('Connect List Phone Numbers >>', error.response) 155 | throw new Error(`${error.response.data.message}`) 156 | }) 157 | console.log(response) 158 | return response.data 159 | } 160 | 161 | export const connectUpdatePhoneNumbers = async (updatePhoneNumberParams) => { 162 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 163 | console.log(cid) 164 | const response = await RestAPI.put('connectAPI', '/connectUpdatePhoneNumbers', { 165 | headers: { 166 | Authorization: cid, 167 | }, 168 | body: updatePhoneNumberParams 169 | }) 170 | .catch(error => { 171 | console.error('Connect Update Phone Numbers >>', error.response) 172 | throw new Error(`${error.response.data.message}`) 173 | }) 174 | return response 175 | } 176 | 177 | export const connectDeleteTrafficDistributionGroup = async (trafficDistributionGroupId) => { 178 | let cid = (await Auth.currentSession()).getIdToken().getJwtToken() 179 | console.log(cid) 180 | const response = await RestAPI.del('connectAPI', '/connectDeleteTrafficDistributionGroup', { 181 | headers: { 182 | Authorization: cid, 183 | }, 184 | body:{trafficDistributionGroupId} 185 | }) 186 | .catch(error => { 187 | console.error('Connect Delete Traffic Distribution Group >>', error.response) 188 | throw new Error(`${error.response.data.message}`) 189 | }) 190 | return response.data 191 | } -------------------------------------------------------------------------------- /webapp/src/components/ConfirmationModal.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, forwardRef } from 'react' 5 | 6 | import { 7 | Modal, 8 | Button, 9 | Inline, 10 | Text, 11 | Box 12 | } from 'aws-northstar' 13 | 14 | const ConfirmationModal = forwardRef((props, ref) => { 15 | const [message, setMessage] = useState() 16 | const [title, setTitle] = useState() 17 | const [visible, setVisible] = useState(false) 18 | 19 | const show = (title, message) => { 20 | setTitle(title) 21 | setMessage(message) 22 | setVisible(true) 23 | } 24 | 25 | ref.current = { 26 | show 27 | } 28 | 29 | const confirmClickHandler = async () => { 30 | if (typeof props.onConfirm === 'function') { 31 | props.onConfirm() 32 | setVisible(false) 33 | } 34 | } 35 | return ( 36 | setVisible(false)}> 37 | {message} 38 | 39 | 40 | 43 | 46 | 47 | 48 | 49 | ) 50 | }) 51 | ConfirmationModal.displayName = 'ConfirmationModal' 52 | export default ConfirmationModal 53 | -------------------------------------------------------------------------------- /webapp/src/components/Notifications.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useEffect, useState } from 'react' 5 | 6 | import { Flashbar } from 'aws-northstar' 7 | 8 | import { useAppState } from '../providers/AppStateProvider' 9 | 10 | const Notifications = () => { 11 | const { notificationItem, pushNotificationItem } = useAppState() 12 | const [items, setItems] = useState([]) 13 | 14 | /* 15 | This limits to just one notification at a time. Consider moving to a Provider in v2. 16 | */ 17 | const onNotificationDismiss = () => { 18 | pushNotificationItem({}) 19 | setItems([]) 20 | } 21 | 22 | useEffect(() => { 23 | if(notificationItem.type){ // Flashbar will allow the render of an empty object 24 | notificationItem.onDismiss = onNotificationDismiss 25 | setItems(items => [...items, notificationItem]) 26 | } 27 | }, [notificationItem]) 28 | 29 | return ( 30 | 31 | ) 32 | } 33 | 34 | export default Notifications -------------------------------------------------------------------------------- /webapp/src/components/SignoutButton.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react' 5 | 6 | import {Auth} from 'aws-amplify' 7 | import Button from 'aws-northstar/components/Button' 8 | import Text from 'aws-northstar/components/Text' 9 | import Inline from 'aws-northstar/layouts/Inline' 10 | 11 | function SignoutButton(props) { 12 | const signOut = (e) => { 13 | e.preventDefault() 14 | console.log('sign out') 15 | Auth.signOut() 16 | .then(() => window.location.reload()) 17 | .catch(error => console.log(error)) 18 | } 19 | return ( 20 | 21 | {props.username} 22 | 25 | 26 | ) 27 | } 28 | 29 | export default SignoutButton -------------------------------------------------------------------------------- /webapp/src/constants/routes.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const routes = { 5 | HOME: '/', 6 | INSTANCE: '/instance', 7 | TRAFFIC_DISTRIBUTION_GROUP: '/instance/:instanceId?/trafficdistributiongroup', 8 | MANAGE_PHONE_NUMBERS: '/instance/:instanceId?/trafficdistributiongroup/:tdgId?/manageNumbers', 9 | } 10 | 11 | export default routes -------------------------------------------------------------------------------- /webapp/src/hooks/useNonInitialEffect.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import {useRef, useEffect} from 'react' 5 | 6 | export default function useNonInitialEffect(effect, deps){ 7 | const initialRender = useRef(true) 8 | 9 | useEffect(() => { 10 | let effectReturns = () => {} 11 | if(initialRender.current){ 12 | initialRender.current = false 13 | } 14 | else{ 15 | effectReturns = effect() 16 | } 17 | 18 | if(effectReturns && typeof effectReturns==='function'){ 19 | return effectReturns 20 | } 21 | // eslint-disable-next-line react-hooks/exhaustive-deps 22 | }, deps) 23 | 24 | } -------------------------------------------------------------------------------- /webapp/src/hooks/useToggle.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { useState } from 'react' 5 | 6 | export default function useToggle(initialState) { 7 | const [isActive, setIsActive] = useState(initialState) 8 | 9 | function toggle() { 10 | setIsActive(!isActive) 11 | } 12 | 13 | return { 14 | isActive, 15 | toggle, 16 | } 17 | } -------------------------------------------------------------------------------- /webapp/src/images/dashboard-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/webapp/src/images/dashboard-logo-dark.png -------------------------------------------------------------------------------- /webapp/src/images/dashboard-logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/webapp/src/images/dashboard-logo-light.png -------------------------------------------------------------------------------- /webapp/src/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-connect-global-resiliency/de77af9e601276062bc7204ebc4c6129b40463b0/webapp/src/images/favicon.ico -------------------------------------------------------------------------------- /webapp/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | */ 5 | 6 | body { 7 | margin: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 9 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | background-color: rgb(242, 242, 242) 14 | } 15 | 16 | code { 17 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 18 | monospace; 19 | } 20 | 21 | amplify-greetings { 22 | --background-color: #192a38; 23 | --border-color: #192a38; 24 | color: #eff0f4; 25 | padding: 0; 26 | } 27 | 28 | .header-title { 29 | --zindex-level-0: 200; 30 | --zindex-level-1: 100; 31 | --global-margin: 0px; 32 | --global-box-shadow: 0 2px 4px #00000050; 33 | --card-box-shadow: 0 2px 3px #00000020; 34 | --spacing-large: 30px; 35 | --spacing-medium: 25px; 36 | --global-spacing: 20px; 37 | --spacing-small: 15px; 38 | --spacing-extra-small: 10px; 39 | --spacing-mini: 5px; 40 | --spacing-head-row: 100px; 41 | --spacing-alert-row: 120px; 42 | --top-row-spacing-small: 40px; 43 | --top-row-spacing-medium: 60px; 44 | --top-row-spacing-large: 70px; 45 | --top-row-spacing--extra-large: 120px; 46 | --navigation-width: 50px; 47 | 48 | --font-size-medium: 24px; 49 | --color-very-dark-blue: #26303B; 50 | --color-dark-blue: #607794; 51 | --color-black: #000000; 52 | --color-white: #ffffff; 53 | --color-alert: #f89406; 54 | --color-light-gray: #0000008a; 55 | --color-very-light-gray: #cccccc; 56 | --color-poor-state: #FE6B5F; 57 | --color-sentiment-category-tag: #DDEDF1; 58 | --color-body-font: #000000bd; 59 | --color-subtext-font: #0000009e; 60 | --icon-small: 22px; 61 | --main-canvas-background: #f5f5f5; 62 | --header-height: 60px; 63 | --row-height: 35px; 64 | --grey-line: 2px solid #e6e6e6; 65 | 66 | text-rendering: optimizelegibility; 67 | line-height: 40px; 68 | font-family: var(--font-medium); 69 | font-size: var(--font-size-medium); 70 | margin: var(--global-margin); 71 | display: inline-block; 72 | font-weight:bold !important; 73 | color: var(--color-white); 74 | margin-right: 40px; 75 | padding-left: 10px; 76 | } -------------------------------------------------------------------------------- /webapp/src/index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | Connect Global Resiliency 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /webapp/src/index.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react' 5 | import ReactDOM from 'react-dom' 6 | 7 | import { Amplify } from '@aws-amplify/core' 8 | import NorthStarThemeProvider from 'aws-northstar/components/NorthStarThemeProvider' 9 | 10 | import App from './App' 11 | import { AppConfigProvider } from './providers/AppConfigProvider' 12 | import { AppStateProvider } from './providers/AppStateProvider' 13 | import './index.css' 14 | 15 | const isFederateLogin = window.location.search === '?federate' ? true : false 16 | const isFederateLogout = window.location.search === '?logout' ? true : false 17 | const webappConfig = window.webappConfig 18 | 19 | //Configure Amplify - using CDK outputs 20 | const amplifyAuthConfig = { 21 | userPoolId: webappConfig.userPoolId, 22 | userPoolWebClientId: webappConfig.userPoolWebClientId, 23 | region: webappConfig.backendRegion 24 | } 25 | 26 | //federation 27 | if (webappConfig.cognitoSAMLEnabled === 'true') { 28 | amplifyAuthConfig['oauth'] = { 29 | domain: webappConfig.cognitoDomainURL.replace(/(^\w+:|^)\/\//, ''), 30 | scope: ['email', 'openid', 'aws.cognito.signin.user.admin', 'profile'], 31 | redirectSignIn: `${window.location.protocol}//${window.location.host}`, 32 | redirectSignOut: `${window.location.protocol}//${window.location.host}/?logout`, 33 | responseType: 'code', 34 | label: 'Sign in with SSO', 35 | customProvider: 'AWSSSO' 36 | } 37 | } 38 | 39 | const amplifyAPIConfig = { 40 | endpoints: [ 41 | { 42 | name: 'connectAPI', 43 | endpoint: webappConfig.connectAPI.replace(/\/$/, ''), 44 | region: amplifyAuthConfig.region 45 | }, 46 | ] 47 | } 48 | 49 | Amplify.configure({ 50 | Auth: amplifyAuthConfig, 51 | API: amplifyAPIConfig 52 | }) 53 | 54 | ReactDOM.render( 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | , 64 | document.getElementById('root') 65 | ) 66 | -------------------------------------------------------------------------------- /webapp/src/providers/AppConfigProvider.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useContext } from 'react' 5 | 6 | const AppConfigContext = React.createContext(null) 7 | 8 | export function useAppConfig() { 9 | const config = useContext(AppConfigContext) 10 | 11 | if (!config) throw new Error('useAppConfig must be used within AppConfigProvider') 12 | 13 | return config 14 | } 15 | 16 | export function AppConfigProvider({ webappConfig, children }) { 17 | 18 | const cognitoSAMLEnabled = getBoolParamValue(webappConfig.cognitoSAMLEnabled) 19 | const cognitoSAMLIdentityProviderName = getParamValue(webappConfig.cognitoSAMLIdentityProviderName) 20 | const backendRegion = getParamValue(webappConfig.backendRegion) 21 | 22 | 23 | const providerValue = { 24 | cognitoSAMLEnabled, 25 | cognitoSAMLIdentityProviderName, 26 | backendRegion 27 | } 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ) 34 | 35 | } 36 | 37 | function getParamValue(param) { 38 | const SSM_NOT_DEFINED = 'not-defined' 39 | if (param === SSM_NOT_DEFINED) return undefined 40 | return param 41 | } 42 | 43 | function getBoolParamValue(param) { 44 | return param === 'true' 45 | } -------------------------------------------------------------------------------- /webapp/src/providers/AppStateProvider.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useContext, useState } from 'react' 5 | 6 | const AppStateContext = React.createContext(null) 7 | 8 | export function useAppState() { 9 | const state = useContext(AppStateContext) 10 | 11 | if (!state) { 12 | throw new Error('useAppState must be used within AppStateProvider') 13 | } 14 | 15 | return state 16 | } 17 | 18 | export function AppStateProvider({ children }) { 19 | const [authState, setAuthState] = useState('') 20 | const [cognitoName, setCognitoName] = useState('') 21 | const [cognitoUsername, setCognitoUsername] = useState('') 22 | const [currentConnectInstance, setCurrentConnectInstance] = useState({}) 23 | const [currentTDG, setCurrentTDG] = useState({}) 24 | const [notificationItem, setNotificationItem] = useState({}) 25 | 26 | 27 | const setCognitoUser = ( 28 | iCognitoUsername, 29 | iCognitoName 30 | ) => { 31 | console.log(`AppStateProvider >> setCognitoUser >> ${iCognitoUsername} > ${iCognitoName}`) 32 | setCognitoUsername(iCognitoUsername) 33 | setCognitoName(iCognitoName) 34 | } 35 | 36 | const pushNotificationItem = (item) => { 37 | setNotificationItem(item) 38 | } 39 | 40 | const providerValue = { 41 | cognitoName, 42 | cognitoUsername, 43 | authState, 44 | setCognitoUser, 45 | setAuthState, 46 | currentConnectInstance, 47 | setCurrentConnectInstance, 48 | currentTDG, 49 | setCurrentTDG, 50 | notificationItem, 51 | pushNotificationItem 52 | } 53 | 54 | return ( 55 | 56 | {children} 57 | 58 | ) 59 | } -------------------------------------------------------------------------------- /webapp/src/views/Instance.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, {useState, useEffect, useRef} from 'react' 5 | 6 | import { useHistory } from 'react-router-dom' 7 | import { useParams } from 'react-router-dom' 8 | 9 | import { 10 | connectReplicateInstance, 11 | connectShowInstance, 12 | connectCreateTrafficDistributionGroup, 13 | connectListTrafficDistributionGroups, 14 | connectDeleteTrafficDistributionGroup, 15 | } from '../apis/connectAPI' 16 | import {useAppState} from '../providers/AppStateProvider' 17 | import { 18 | Badge, 19 | Box, 20 | Button, 21 | Column, 22 | ColumnLayout, 23 | componentTypes, 24 | Container, 25 | FormRenderer, 26 | Heading, 27 | Inline, 28 | KeyValuePair, 29 | LoadingIndicator, 30 | Modal, 31 | Overlay, 32 | Stack, 33 | Table, 34 | Text, 35 | validatorTypes, 36 | Link 37 | } from 'aws-northstar' 38 | 39 | import ConfirmationModal from '../components/ConfirmationModal' 40 | 41 | const Instance = () => { 42 | const { instanceId } = useParams() 43 | const { setCurrentConnectInstance, setCurrentTDG, pushNotificationItem } = useAppState() 44 | const history = useHistory() 45 | const confirmationModalRef = useRef(null) 46 | 47 | const [instance, setInstance] = useState({}) 48 | const [loading, setLoading] = useState(false) 49 | const [replicaModalSubmitting, setReplicaModalSubmitting] = useState(false) 50 | const [replicaModalVisible, setReplicaModalVisible] = useState(false) 51 | const [tdgLoading, setTDGLoading] = useState(false) 52 | const [tdgModalVisible, setTdgModalVisible] = useState(false) 53 | const [trafficDistributionGroups, setTrafficDistributionGroups] = useState([]) 54 | 55 | let selectedTdg = {} 56 | 57 | useEffect(async () => { 58 | if (instanceId) { 59 | await showInstance() 60 | await listTDG() 61 | } 62 | else { 63 | console.log('No instanceId passed in URL, redirecting back to Home') 64 | history.replace('/') 65 | 66 | } 67 | 68 | }, []) 69 | 70 | const navigateToTDG = async () => { 71 | console.debug(`Selected item is ${selectedTdg}`) 72 | if (selectedTdg.Id) { 73 | setCurrentTDG(selectedTdg) 74 | const tdgId = selectedTdg.Id 75 | history.replace(`/instance/${instanceId}/trafficdistributiongroup/${tdgId}/`) 76 | } 77 | else { 78 | console.error('no items selected') 79 | } 80 | } 81 | 82 | /** 83 | * Table Events 84 | */ 85 | 86 | const onTdgSelectionChange = async (index) => { 87 | if (trafficDistributionGroups && index.length > 0) { 88 | selectedTdg = trafficDistributionGroups.find(x => x.Id === index[0]) 89 | } 90 | } 91 | 92 | const onTdgClick = async (id) => { 93 | if (trafficDistributionGroups && id) { 94 | selectedTdg = trafficDistributionGroups.find(x => x.Id === id) 95 | setCurrentTDG(selectedTdg) 96 | history.replace(`/instance/${instanceId}/trafficdistributiongroup/${id}/`) 97 | } 98 | } 99 | 100 | /** 101 | * Show instance section and actions 102 | */ 103 | 104 | const showInstance = async () => { 105 | console.log('connectShowInstance') 106 | setLoading(true) 107 | try { 108 | const connectShowInstanceResult = await connectShowInstance(instanceId) 109 | setInstance(connectShowInstanceResult) 110 | setCurrentConnectInstance(connectShowInstanceResult) 111 | console.log('connectShowInstanceResult', connectShowInstanceResult) 112 | setLoading(false) 113 | } 114 | catch (error) { 115 | console.error('Error showing instance:',error) 116 | setLoading(false) 117 | pushNotificationItem({ 118 | header: 'There was an error retrieving instance details', 119 | content: error.message, 120 | type: 'error', 121 | dismissible: true, 122 | }) 123 | } 124 | 125 | } 126 | 127 | const replicateAction = ( 128 | 129 | 142 | 143 | ) 144 | 145 | 146 | /** 147 | * Replicate Instance pop-up form and actions 148 | */ 149 | const connectReplicateForm = async (formFields) => { 150 | console.debug('connectReplicateInstance') 151 | setReplicaModalSubmitting(true) 152 | setLoading(true) 153 | 154 | try { 155 | const replicaAlias = formFields['replicaInstanceAliasValue'] 156 | const connectReplicateInstanceResult = await connectReplicateInstance(instance.Id, replicaAlias) 157 | console.log('connectReplicateInstanceResult', connectReplicateInstanceResult) 158 | 159 | pushNotificationItem({ 160 | header: 'Successfully replicated instance. It will take a few moments before the replica becomes available. Please log into the AWS Console to configure the new instance.', 161 | type: 'success', 162 | dismissible: true, 163 | }) 164 | } catch (error) { 165 | console.error('Error replicating instance', error) 166 | pushNotificationItem({ 167 | header: 'There was an error replicating the instance', 168 | content: error.message, 169 | type: 'error', 170 | dismissible: true, 171 | }) 172 | } 173 | 174 | setReplicaModalSubmitting(false) 175 | setReplicaModalVisible(false) 176 | setLoading(false) 177 | 178 | } 179 | 180 | const replicateInstanceSchema = { 181 | fields: [ 182 | { 183 | component: componentTypes.TEXT_FIELD, 184 | name: 'replicaInstanceAliasValue', 185 | label: 'Replica alias', 186 | isRequired: true, 187 | validate: [ 188 | { 189 | type: validatorTypes.REQUIRED, 190 | }, 191 | { 192 | type: validatorTypes.PATTERN, 193 | pattern: /^(?!d-)([\da-zA-Z]+)([-]*[\da-zA-Z])*$/ 194 | } 195 | ], 196 | }, 197 | ] 198 | } 199 | 200 | const replicateModal = ( 201 | setReplicaModalVisible(false)}> 202 | 203 | NOTE: Once you create a replica it will be initially empty. You will need to log into the AWS Console and set up any configuration needed before sending any traffic to it. 204 | 205 | connectReplicateForm(value)} onCancel={() => setReplicaModalVisible(false)} isCreateReplicaSubmitting={replicaModalSubmitting}/> 206 | 207 | ) 208 | 209 | /** 210 | * Remove traffic distribution group confirmation and actions 211 | */ 212 | 213 | const onClickDeleteTdg = async () => { 214 | if (selectedTdg.Id) { 215 | confirmationModalRef.current.show('Delete Traffic Distribution Group', 216 | ['This will delete this traffic distribution group. You cannot delete if there are any phone numbers currently associated with this traffic distribution group.',
,
, 'Are you sure you want to continue?']) 217 | } 218 | } 219 | 220 | 221 | const deleteTdg = async () => { 222 | console.debug('Deleting tdg:', selectedTdg?.Id) 223 | setLoading(true) 224 | 225 | 226 | //Only proceed if an item is selected 227 | if (selectedTdg?.Id) { 228 | try { 229 | 230 | const connectDeleteTrafficDistributionGroupResult = await connectDeleteTrafficDistributionGroup(selectedTdg.Id) 231 | console.debug('connectDeleteTrafficDistributionGroupResult', connectDeleteTrafficDistributionGroupResult) 232 | 233 | pushNotificationItem({ 234 | header: 'Successfully deleted traffic distribution group. This may take a few moments to complete and you will need to refresh the table to see the updates.', 235 | type: 'success', 236 | dismissible: true, 237 | }) 238 | 239 | //Reload TDGs 240 | await listTDG() 241 | 242 | } catch (error) { 243 | console.error('Error adding traffic distribution group', error) 244 | pushNotificationItem({ 245 | header: 'There was an error deleting traffic distribution group:', 246 | content: error.message, 247 | type: 'error', 248 | dismissible: true, 249 | }) 250 | } 251 | } 252 | else { 253 | console.log('No traffic distribution group selected') 254 | } 255 | setLoading(false) 256 | 257 | 258 | } 259 | 260 | /** 261 | * Traffic distribution group table and actions 262 | */ 263 | 264 | const getRowId = React.useCallback(data => data.Id, []) 265 | const listTDG = async () => { 266 | console.debug('listTrafficDistributionGroups') 267 | setTDGLoading(true) 268 | 269 | try { 270 | const connectListTrafficDistributionGroupsResult = await connectListTrafficDistributionGroups(instanceId) 271 | setTrafficDistributionGroups(connectListTrafficDistributionGroupsResult) 272 | console.debug('listTrafficDistributionGroups', connectListTrafficDistributionGroupsResult) 273 | 274 | } catch (error) { 275 | console.error('error calling listTrafficDistributionGroups', error) 276 | 277 | pushNotificationItem({ 278 | header: 'There was an error loading the traffic distribution groups', 279 | content: error.message, 280 | type: 'error', 281 | dismissible: true, 282 | }) 283 | } 284 | setTDGLoading(false) 285 | 286 | } 287 | 288 | 289 | const tdgColumnDefinitions = [ 290 | { 291 | id: 'Name', 292 | Header: 'Name', 293 | accessor: 'Name', 294 | width: 200, 295 | Cell: e => onTdgClick(e.row.id)} href='#'>{e.value} 296 | }, 297 | { 298 | id: 'Id', 299 | Header: 'Id', 300 | accessor: 'Id', 301 | width: 400 302 | }, 303 | { 304 | id: 'Status', 305 | Header: 'Status', 306 | accessor: 'Status', 307 | width: 100 308 | } 309 | ] 310 | 311 | const tdgTableActions = ( 312 | 313 | 326 | 333 | 339 | 340 | ) 341 | 342 | 343 | 344 | /** 345 | * Add traffic distribution group form and actions 346 | */ 347 | const connectAddTDGForm = async (formFields) => { 348 | console.debug('connectAddTDGForm', formFields) 349 | setLoading(true) 350 | 351 | 352 | try { 353 | const name = formFields['tdgName'] 354 | const description = formFields['tdgDescription'] 355 | const connectReplicateInstanceResult = await connectCreateTrafficDistributionGroup(name, description, instanceId) 356 | console.debug('connectAddTDGForm', connectReplicateInstanceResult) 357 | 358 | //Reload TDGs 359 | await listTDG() 360 | 361 | pushNotificationItem({ 362 | header: 'Successfully added new traffic distribution group. This may take a few moments to complete and you will need to refresh the table to see the updates.', 363 | type: 'success', 364 | dismissible: true, 365 | }) 366 | } catch (error) { 367 | console.error('Error adding traffic distribution group', error) 368 | pushNotificationItem({ 369 | header: 'There was an error adding a new traffic distribution group', 370 | content: error.message, 371 | type: 'error', 372 | dismissible: true, 373 | }) 374 | } 375 | 376 | setTdgModalVisible(false) 377 | setLoading(false) 378 | 379 | } 380 | 381 | 382 | const addTdgInstanceSchema = { 383 | fields: [ 384 | { 385 | component: componentTypes.TEXT_FIELD, 386 | name: 'tdgName', 387 | label: 'Name', 388 | isRequired: true, 389 | validate: [ 390 | { 391 | type: validatorTypes.REQUIRED, 392 | }, 393 | { 394 | type: validatorTypes.PATTERN, 395 | pattern: /(^[\S].*[\S]$)|(^[\S]$)/ 396 | } 397 | ], 398 | }, 399 | { 400 | component: componentTypes.TEXTAREA, 401 | name: 'tdgDescription', 402 | label: 'Description', 403 | validate: [ 404 | { 405 | type: validatorTypes.PATTERN, 406 | pattern: /(^[\S].*[\S]$)|(^[\S]$)/ 407 | } 408 | ], 409 | }, 410 | ] 411 | } 412 | 413 | const addTDGModal = ( 414 | setTdgModalVisible(false)}> 415 | 416 | NOTE: Once your traffic distribution group is created successfully (Status is ACTIVE), you can reassign available phone numbers to it by clicking the Manage button. 417 | 418 | connectAddTDGForm(value)} onCancel={() => setTdgModalVisible(false)}/> 419 | 420 | ) 421 | 422 | 423 | 424 | return ( 425 |
426 | 427 | Instance overview 428 | 429 | 430 | 434 | 435 | 436 | 437 | {instance.Replicated === 'true' && instance.PrimaryReplica === false && instance.ReplicaAlias && } 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | { 447 | instance.ReplicaAlias && instance.Replicated && 448 | 449 | } 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 471 | 472 | { 473 | instance.PrimaryReplica === false && 474 | NOTE: Traffic distribution groups can only be added in the region of the instance that was originally replicated. 475 | } 476 | 477 | 478 | {loading && 479 | 480 | 481 | 482 | } 483 | 484 | 485 | {replicateModal} 486 | {addTDGModal} 487 | 488 | 489 | 490 | ) 491 | } 492 | 493 | export default Instance 494 | -------------------------------------------------------------------------------- /webapp/src/views/InstanceList.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, {useEffect, useState} from 'react' 5 | import { useHistory } from 'react-router-dom' 6 | 7 | import {Box, LoadingIndicator, Overlay} from 'aws-northstar' 8 | import Button from 'aws-northstar/components/Button' 9 | import Container from 'aws-northstar/layouts/Container' 10 | import Inline from 'aws-northstar/layouts/Inline' 11 | import Table from 'aws-northstar/components/Table' 12 | import Text from 'aws-northstar/components/Text' 13 | import Link from 'aws-northstar/components/Link' 14 | 15 | import {connectListInstances} from '../apis/connectAPI' 16 | import routes from '../constants/routes' 17 | import {useAppState} from '../providers/AppStateProvider' 18 | 19 | const InstanceList = () => { 20 | const { pushNotificationItem } = useAppState() 21 | const history = useHistory() 22 | 23 | const [instanceList, setInstanceList] = useState([]) 24 | const [loading, setLoading] = useState() 25 | 26 | let selectedInstanceId 27 | 28 | useEffect(async () => { 29 | await connectListInstancesLoad() 30 | }, []) 31 | 32 | const connectListInstancesLoad = async () => { 33 | setLoading(true) 34 | 35 | try{ 36 | console.debug('Calling connectListInstances') 37 | const connectListInstancesResult = await connectListInstances() 38 | 39 | setInstanceList(connectListInstancesResult) 40 | console.debug('connectListInstancesResult', connectListInstancesResult) 41 | setLoading(false) 42 | } catch (error){ 43 | console.error('Error listing instances', error) 44 | setLoading(false) 45 | pushNotificationItem({ 46 | header: 'There was an error retrieving instances', 47 | content: error.message, 48 | type: 'error', 49 | dismissible: true, 50 | }) 51 | } 52 | } 53 | 54 | 55 | // Available fields on ListConnectInstances: Arn, CreatedTime, Id, IdentityManagementType, InboundCallsEnabled, 56 | // InstanceAlias, InstanceStatus, OutboundCallsEnabled, ServiceRole 57 | const columnDefinitions = [ 58 | { 59 | id: 'id', 60 | Header: 'Alias', 61 | accessor: 'InstanceAlias', 62 | width: 300, 63 | Cell: e =>{e.value} 64 | }, 65 | { 66 | id: 'status', 67 | Header: 'Status', 68 | accessor: 'InstanceStatus', 69 | width: 120 70 | }, 71 | { 72 | id: 'date', 73 | Header: 'Created', 74 | accessor: 'Date', 75 | width: 200 76 | }, 77 | { 78 | id: 'arn', 79 | Header: 'Arn', 80 | accessor: 'Arn', 81 | width: 500 82 | } 83 | ] 84 | 85 | const navigateToInstance = async () => { 86 | console.debug(`Selected item is ${selectedInstanceId}`) 87 | if (selectedInstanceId) { 88 | history.replace(`${routes.INSTANCE}/${selectedInstanceId}`) 89 | } 90 | else { 91 | console.error('no items selected') 92 | } 93 | } 94 | 95 | /** 96 | * Table Events 97 | */ 98 | 99 | const getRowId = React.useCallback(data => data.Id, []) 100 | const onConnectInstanceChange = async (id) => { 101 | console.debug('onConnectInstanceChange', id) 102 | if (id.length > 0) { 103 | // multi-select is turned off, so take just the first item 104 | selectedInstanceId = id[0] 105 | } 106 | } 107 | 108 | const tableActions = ( 109 | 110 | 119 | 120 | ) 121 | 122 | return ( 123 |
124 | 125 | 128 | 129 | 130 | Amazon Connect Global Resiliency allows you to set up your Amazon Connect contact center to run across multiple AWS Regions. 131 | This dashboard provides a front end to interact with the APIs laid out in the Amazon Connect documentation. 132 | To get started choose an Instance below. 133 | 134 | 135 | 136 |
149 | 150 | 151 | NOTE: This dashboard should be deployed into both regions where your initial and replica instances are located so you can use either to redistribute traffic. 152 | 153 | 154 | 155 | {loading && 156 | 157 | 158 | } 159 | 160 | 161 | 162 | ) 163 | } 164 | 165 | export default InstanceList -------------------------------------------------------------------------------- /webapp/src/views/ManageNumbers.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, {useState, useEffect} from 'react' 5 | import { useParams, useHistory } from 'react-router-dom' 6 | 7 | import Box from 'aws-northstar/layouts/Box' 8 | import Button from 'aws-northstar/components/Button' 9 | import Inline from 'aws-northstar/layouts/Inline' 10 | import {Heading, LoadingIndicator, Modal, Overlay} from 'aws-northstar' 11 | import Table from 'aws-northstar/components/Table' 12 | import Tabs from 'aws-northstar/components/Tabs' 13 | import Text from 'aws-northstar/components/Text' 14 | 15 | import {useAppState} from '../providers/AppStateProvider' 16 | import { 17 | connectListPhoneNumbers, 18 | connectUpdatePhoneNumbers, 19 | connectShowInstance, 20 | connectListTrafficDistributionGroups 21 | } from '../apis/connectAPI' 22 | 23 | const ManageNumbers = () => { 24 | const { instanceId, tdgId } = useParams() 25 | const { currentConnectInstance, setCurrentConnectInstance, currentTDG, setCurrentTDG, pushNotificationItem } = useAppState() 26 | const history = useHistory() 27 | 28 | const [loading, setLoading] = useState(false) 29 | const [phoneNumbersAssociatedToInstance, setPhoneNumbersAssociatedToInstance] = useState([]) 30 | const [phoneNumbersAssociatedToTDG, setPhoneNumbersAssociatedToTDG] = useState([]) 31 | const [reassignPhoneNumberModalVisible, setReassignPhoneNumberModalVisible] = useState(false) 32 | const [releasePhoneNumberModalVisible, setReleasePhoneNumberModalVisible] = useState(false) 33 | 34 | let selectedInstancePhoneNumberIds = [] 35 | let selectedTDGPhoneNumberIds = [] 36 | const sleep = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)) } 37 | 38 | useEffect(async () => { 39 | 40 | console.debug('currentConnectInstance', currentConnectInstance) 41 | console.debug('currentTDG', currentTDG) 42 | 43 | if (currentConnectInstance?.Arn && currentTDG?.Arn) { 44 | await refreshPhoneNumbers() 45 | } 46 | else if(instanceId && tdgId) { 47 | //page refresh or direct link - need to fetch tdgs so we can find the ARN of the given tdg.Id as ids are the same across regions 48 | setLoading(true) 49 | try { 50 | const connectShowInstanceResult = await connectShowInstance(instanceId) 51 | setCurrentConnectInstance(connectShowInstanceResult) 52 | console.debug('connectShowInstanceResult', connectShowInstanceResult) 53 | 54 | //Only load in instance numbers if the user will be able to view and use them 55 | if (connectShowInstanceResult.PrimaryReplica) { 56 | const listInstancePhoneNumberResponse = await listPhoneNumbers(connectShowInstanceResult.Arn) 57 | setPhoneNumbersAssociatedToInstance(listInstancePhoneNumberResponse) 58 | } 59 | 60 | const connectListTrafficDistributionGroupsResult = await connectListTrafficDistributionGroups(instanceId) 61 | console.debug('listTrafficDistributionGroups', connectListTrafficDistributionGroupsResult) 62 | const foundTdg = connectListTrafficDistributionGroupsResult.find(x => x.Id === tdgId) 63 | if (foundTdg){ 64 | setCurrentTDG(foundTdg) 65 | const listTDGPhoneNumberResponse = await listPhoneNumbers(foundTdg.Arn) 66 | setPhoneNumbersAssociatedToTDG(listTDGPhoneNumberResponse) 67 | 68 | setLoading(false) 69 | } else { 70 | throw new Error('Unknown Traffic Distribution Group.') 71 | } 72 | 73 | } catch (error) { 74 | console.error('error calling retrieving instance or traffic distribution groups', error) 75 | setLoading(false) 76 | pushNotificationItem({ 77 | header: 'There was an error loading the instance or traffic distribution groups', 78 | content: error.message, 79 | type: 'error', 80 | dismissible: true, 81 | }) 82 | } 83 | } 84 | else { 85 | console.error('No instanceId or tdgId passed in URL') 86 | //If the params are missing in the URL navigate back to Home 87 | history.replace('/') 88 | } 89 | 90 | }, []) 91 | 92 | const refreshPhoneNumbers = async (delay= 0) => { 93 | setLoading(true) 94 | await sleep(delay) 95 | 96 | try{ 97 | const listTDGPhoneNumberResponse = await listPhoneNumbers(currentTDG.Arn) 98 | setPhoneNumbersAssociatedToTDG(listTDGPhoneNumberResponse) 99 | 100 | //Only load in instance numbers if the user will be able to view and use them 101 | if (currentConnectInstance.PrimaryReplica) { 102 | const listInstancePhoneNumberResponse = await listPhoneNumbers(currentConnectInstance.Arn) 103 | setPhoneNumbersAssociatedToInstance(listInstancePhoneNumberResponse) 104 | } 105 | 106 | setLoading(false) 107 | } 108 | catch (error){ 109 | console.error('error retrieving phone numbers', error) 110 | setLoading(false) 111 | pushNotificationItem({ 112 | header: 'There was an error retrieving phone numbers', 113 | content: error.message, 114 | type: 'error', 115 | dismissible: true, 116 | }) 117 | } 118 | } 119 | 120 | const listPhoneNumbers = async (arn) => { 121 | try{ 122 | let connectListPhoneNumbersInstanceResult, nextToken 123 | let accumulated = [] 124 | 125 | do { 126 | connectListPhoneNumbersInstanceResult = await connectListPhoneNumbers({ 127 | MaxResults: 1000, 128 | TargetArn: arn, 129 | NextToken: nextToken 130 | }) 131 | nextToken = connectListPhoneNumbersInstanceResult.NextToken 132 | accumulated = [...accumulated, ...connectListPhoneNumbersInstanceResult.ListPhoneNumbersSummaryList] 133 | } while (connectListPhoneNumbersInstanceResult.NextToken) 134 | 135 | return accumulated 136 | 137 | } catch(error){ 138 | pushNotificationItem( 139 | { 140 | header: `Error Loading Phone Numbers for Arn: ${arn}`, 141 | content: error.message, 142 | type: 'error', 143 | dismissible: true, 144 | } 145 | ) 146 | } 147 | } 148 | 149 | /** 150 | * TDG phone number table and actions 151 | */ 152 | const phoneNumberTableColumnDefinitions = [ 153 | { 154 | id: 'PhoneNumber', 155 | width: 150, 156 | Header: 'Phone number', 157 | accessor: 'PhoneNumber' 158 | }, 159 | { 160 | id: 'PhoneNumberCountryCode', 161 | width: 100, 162 | Header: 'Country code', 163 | accessor: 'PhoneNumberCountryCode' 164 | }, 165 | { 166 | id: 'PhoneNumberType', 167 | width: 100, 168 | Header: 'Type', 169 | accessor: 'PhoneNumberType' 170 | }, 171 | ] 172 | 173 | const tableActionsForTDGNumbers = ( 174 | 175 | 185 | } 186 | 187 | ) 188 | 189 | const onTDGPhoneNumberSelectionChange = async (index) => { 190 | console.log('onTDGPhoneNumberSelectionChange', index) 191 | if (phoneNumbersAssociatedToTDG && phoneNumbersAssociatedToTDG.length > 0 && index.length > 0) { 192 | selectedTDGPhoneNumberIds = index.map((item) => phoneNumbersAssociatedToTDG[item].PhoneNumberId) 193 | } 194 | } 195 | 196 | const releasePhoneNumberClick = async () => { 197 | 198 | console.log('releasePhoneNumber', selectedTDGPhoneNumberIds) 199 | 200 | if (selectedTDGPhoneNumberIds && selectedTDGPhoneNumberIds.length > 0){ 201 | try{ 202 | setLoading(true) 203 | const connectUpdatePhoneNumbersResult = await connectUpdatePhoneNumbers({ 204 | targetArn: currentConnectInstance.Arn, 205 | phoneNumberIds: selectedTDGPhoneNumberIds 206 | }) 207 | console.log(connectUpdatePhoneNumbersResult) 208 | 209 | if (connectUpdatePhoneNumbersResult.metadata.success === connectUpdatePhoneNumbersResult.metadata.total){ //Complete Success 210 | pushNotificationItem( 211 | { 212 | header: 'Successfully updated phone numbers. You may need to wait a few minutes and refresh the table to see the changes.', 213 | type: 'success', 214 | dismissible: true, 215 | } 216 | ) 217 | 218 | setReleasePhoneNumberModalVisible(false) 219 | setLoading(false) 220 | await refreshPhoneNumbers(2000) 221 | 222 | } else if (connectUpdatePhoneNumbersResult.metadata.error === connectUpdatePhoneNumbersResult.metadata.total) { //Complete Failure 223 | console.log('error releasing phone numbers') 224 | setReleasePhoneNumberModalVisible(false) 225 | setLoading(false) 226 | pushNotificationItem( 227 | { 228 | header: 'Error Releasing Phone Numbers', 229 | content: connectUpdatePhoneNumbersResult.data.map((item) => `[Phone Number Id: ${item.resource.phoneNumberId} - Status: ${item.status} - Error: (${item.resource.error.statusCode})${item.resource.error.message}]`), 230 | type: 'error', 231 | dismissible: true, 232 | } 233 | ) 234 | } else { //Something in between. 235 | console.log('error releasing some phone numbers') 236 | setReleasePhoneNumberModalVisible(false) 237 | setLoading(false) 238 | pushNotificationItem( 239 | { 240 | header: 'Error Releasing Some Phone Numbers - Please check results below:', 241 | content: connectUpdatePhoneNumbersResult.data.map((item) => `[Phone Number Id: ${item.resource.phoneNumberId} - Status: ${item.status} - Error: (${item.resource.error.statusCode})${item.resource.error.message}]`), 242 | type: 'warning', 243 | dismissible: true, 244 | } 245 | ) 246 | } 247 | } 248 | catch (error){ 249 | console.log('error releasing phone number') 250 | setReleasePhoneNumberModalVisible(false) 251 | setLoading(false) 252 | pushNotificationItem( 253 | { 254 | header: 'Error Releasing Phone Number', 255 | content: error.message, 256 | type: 'error', 257 | dismissible: true, 258 | } 259 | ) 260 | } 261 | } 262 | else { 263 | console.log('no phone numbers selected') 264 | } 265 | } 266 | 267 | const releasePhoneNumberModal = ( setReleasePhoneNumberModalVisible(false)}> 269 | 270 | This will release the phone numbers from the {currentTDG.Name} traffic distribution group 271 | and assign them back to your Instance. Are you sure you want to continue? 272 | 273 | 274 | 277 | 280 | 281 | ) 282 | 283 | 284 | /** 285 | * Add Instance phone number table and actions 286 | */ 287 | 288 | 289 | const onInstancePhoneNumberSelectionChange = async (index) => { 290 | console.log('onInstancePhoneNumberSelectionChange', index) 291 | if (phoneNumbersAssociatedToInstance && phoneNumbersAssociatedToInstance.length > 0 && index.length > 0) { 292 | selectedInstancePhoneNumberIds = index.map((item) => phoneNumbersAssociatedToInstance[item].PhoneNumberId) 293 | } 294 | } 295 | 296 | const reassignPhoneNumberClick = async () => { 297 | console.log('addPhoneNumberClick', selectedInstancePhoneNumberIds) 298 | 299 | if (selectedInstancePhoneNumberIds && selectedInstancePhoneNumberIds.length > 0) { 300 | 301 | try{ 302 | setLoading(true) 303 | const connectUpdatePhoneNumbersResult = await connectUpdatePhoneNumbers({ 304 | targetArn: currentTDG.Arn, 305 | phoneNumberIds: selectedInstancePhoneNumberIds 306 | }) 307 | console.log(connectUpdatePhoneNumbersResult) 308 | 309 | if (connectUpdatePhoneNumbersResult.metadata.success === connectUpdatePhoneNumbersResult.metadata.total){ //Complete Success 310 | pushNotificationItem( 311 | { 312 | header: 'Successfully updated phone numbers. You may need to wait a few minutes and refresh the table to see the changes.', 313 | type: 'success', 314 | dismissible: true, 315 | } 316 | ) 317 | 318 | setReassignPhoneNumberModalVisible(false) 319 | setLoading(false) 320 | await refreshPhoneNumbers(2000) 321 | 322 | } else if (connectUpdatePhoneNumbersResult.metadata.error === connectUpdatePhoneNumbersResult.metadata.total) { //Complete Failure 323 | console.log('error assigning phone numbers') 324 | setReassignPhoneNumberModalVisible(false) 325 | setLoading(false) 326 | pushNotificationItem( 327 | { 328 | header: 'Error assigning phone numbers to traffic distribution group', 329 | content: connectUpdatePhoneNumbersResult.data.map((item) => `[Phone Number Id: ${item.resource.phoneNumberId} - Status: ${item.status} - Error: (${item.resource.error.statusCode})${item.resource.error.message}]`), 330 | type: 'error', 331 | dismissible: true, 332 | } 333 | ) 334 | } else { //Something in between. 335 | console.log('error assigning some phone numbers') 336 | setReassignPhoneNumberModalVisible(false) 337 | setLoading(false) 338 | pushNotificationItem( 339 | { 340 | header: 'Error assigning some phone numbers to traffic distribution group - Please check results below:', 341 | content: connectUpdatePhoneNumbersResult.data.map((item) => `[Phone Number Id: ${item.resource.phoneNumberId} - Status: ${item.status} - Error: (${item.resource.error.statusCode})${item.resource.error.message}]`), 342 | type: 'warning', 343 | dismissible: true, 344 | } 345 | ) 346 | } 347 | } 348 | catch (error){ 349 | console.log('error releasing phone number') 350 | setReassignPhoneNumberModalVisible(false) 351 | setLoading(false) 352 | pushNotificationItem( 353 | { 354 | header: 'Error Assigning Phone Numbers', 355 | content: error.message, 356 | type: 'error', 357 | dismissible: true, 358 | } 359 | ) 360 | } 361 | } 362 | else { 363 | console.log('no phone numbers selected') 364 | } 365 | } 366 | 367 | const reassignPhoneNumberModal = ( setReassignPhoneNumberModalVisible(false)}> 369 | 370 | This will reassign the phone numbers to the {currentTDG.Name} traffic distribution group. This may take a few moments. 371 | Are you sure you want to continue? 372 | 373 | 374 | 377 | 380 | 381 | ) 382 | 383 | const addPhoneNumberButtons = ( 384 | 385 | 394 | ) 395 | 396 | 397 | const managePhoneNumberTabs = [ 398 | { 399 | label: 'Current phone numbers', 400 | id: 'first-tab', 401 | content: 402 | 403 |
413 | 414 | You can only reassign or release 25 numbers at a time from the dashboard. If you need to manage numbers in bulk, please consider using the API directly. 415 | 416 | 417 | } 418 | ] 419 | 420 | // Only show tab for adding on the primary replica 421 | if (currentConnectInstance.PrimaryReplica) { 422 | managePhoneNumberTabs.push( 423 | { 424 | label: '+ Assign additional numbers', 425 | id: 'second-tab', 426 | content: 427 | 428 |
439 | 440 | NOTE: to claim a new phone number to use in your TDG use the standard Amazon Connect console, and then reassign it here.
441 | You can only reassign or release 25 numbers at a time from the dashboard. If you need to manage numbers in bulk, please consider using the API directly. 442 |
443 | 444 | }) 445 | 446 | } 447 | 448 | 449 | 450 | return ( 451 |
452 | 453 | 454 | Manage phone numbers 455 | 456 | 457 | 458 | {releasePhoneNumberModal} 459 | {reassignPhoneNumberModal} 460 | 461 | {loading && 462 | 463 | 464 | 465 | } 466 | 467 |
468 | 469 | ) 470 | } 471 | 472 | export default ManageNumbers 473 | -------------------------------------------------------------------------------- /webapp/src/views/TrafficDistributionGroup.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, {useState, useEffect, useRef} from 'react' 5 | import {useParams} from 'react-router-dom' 6 | import { useHistory } from 'react-router-dom' 7 | 8 | import Button from 'aws-northstar/components/Button' 9 | import { 10 | Box, 11 | Column, 12 | ColumnLayout, 13 | Form, 14 | FormField, 15 | Heading, 16 | Select, 17 | Input, 18 | KeyValuePair, 19 | Modal, 20 | Stack, 21 | Overlay, 22 | LoadingIndicator 23 | } from 'aws-northstar' 24 | import Container from 'aws-northstar/layouts/Container' 25 | import Inline from 'aws-northstar/layouts/Inline' 26 | 27 | import ConfirmationModal from '../components/ConfirmationModal' 28 | import { connectShowTrafficDistributionGroup, connectUpdateTrafficDistribution, connectListTrafficDistributionGroups, connectShowInstance } from '../apis/connectAPI' 29 | import { useAppState } from '../providers/AppStateProvider' 30 | 31 | const TrafficDistributionGroup = () => { 32 | const { tdgId, instanceId } = useParams() 33 | const { pushNotificationItem, currentConnectInstance, setCurrentConnectInstance, currentTDG,setCurrentTDG } = useAppState() 34 | const history = useHistory() 35 | const confirmationModalRef = useRef(null) 36 | 37 | const [editTDModalVisible, setEditTDModalVisible] = useState(false) 38 | const [loading, setLoading] = useState(false) 39 | const [region1, setRegion1] = useState() 40 | const [region1Pct, setRegion1Pct] = useState() 41 | const [region1SelectPct, setRegion1SelectPct] = useState() 42 | const [region2, setRegion2] = useState() 43 | const [region2Pct, setRegion2Pct] = useState() 44 | const [tdg, setTDG] = useState({}) 45 | 46 | const pctOptions = [ 47 | { label: '0%', value: '0' }, 48 | { label: '10%', value: '10' }, 49 | { label: '20%', value: '20' }, 50 | { label: '30%', value: '30' }, 51 | { label: '40%', value: '40' }, 52 | { label: '50%', value: '50' }, 53 | { label: '60%', value: '60' }, 54 | { label: '70%', value: '70' }, 55 | { label: '80%', value: '80' }, 56 | { label: '90%', value: '90' }, 57 | { label: '100%', value: '100' } 58 | ] 59 | 60 | useEffect(async () => { 61 | if (currentTDG.Arn) { 62 | await showTDG(currentTDG.Arn) 63 | } 64 | else if(instanceId && tdgId) { 65 | //page refresh or direct link - need to fetch tdgs so we can find the ARN of the given tdg.Id as ids are the same across regions 66 | setLoading(true) 67 | try { 68 | const connectShowInstanceResult = await connectShowInstance(instanceId) 69 | setCurrentConnectInstance(connectShowInstanceResult) 70 | console.debug('connectShowInstanceResult', connectShowInstanceResult) 71 | 72 | const connectListTrafficDistributionGroupsResult = await connectListTrafficDistributionGroups(instanceId) 73 | console.debug('listTrafficDistributionGroups', connectListTrafficDistributionGroupsResult) 74 | const foundTdg = connectListTrafficDistributionGroupsResult.find(x => x.Id === tdgId) 75 | if (foundTdg){ 76 | await showTDG(foundTdg.Arn) 77 | } else { 78 | throw new Error('Unknown Traffic Distribution Group.') 79 | } 80 | 81 | 82 | } catch (error) { 83 | console.error('error calling retrieving instance or traffic distribution groups', error) 84 | setLoading(false) 85 | pushNotificationItem({ 86 | header: 'There was an error loading the instance or traffic distribution groups', 87 | content: error.message, 88 | type: 'error', 89 | dismissible: true, 90 | }) 91 | } 92 | } 93 | else { 94 | console.error('No instanceId or tdgId passed in URL') 95 | //If the params are missing in the URL navigate back to Home 96 | history.replace('/') 97 | } 98 | 99 | }, []) 100 | 101 | const navigateToManageNumbers = async () => { 102 | history.replace(`/instance/${instanceId}/trafficdistributiongroup/${tdgId}/managenumbers`) 103 | } 104 | 105 | /** 106 | * Show traffic distribution group 107 | */ 108 | const showTDG = async (arn) => { 109 | console.debug('show TDG:', currentTDG) 110 | try{ 111 | setLoading(true) 112 | const connectShowTrafficDistributionGroupResult = await connectShowTrafficDistributionGroup(arn) 113 | console.debug(connectShowTrafficDistributionGroupResult) 114 | const td = connectShowTrafficDistributionGroupResult.TrafficDistributionGroup 115 | setTDG(td) 116 | setCurrentTDG(td) 117 | 118 | td.TrafficDistribution.TelephonyConfig.Distributions.forEach((dist, index) => { 119 | if(index === 0){ 120 | setRegion1(dist.Region) 121 | setRegion1Pct(dist.Percentage) 122 | setRegion1SelectPct(pctOptions.find(o => o.value === dist.Percentage + '')) 123 | } else { 124 | setRegion2(dist.Region) 125 | setRegion2Pct(dist.Percentage) 126 | } 127 | }) 128 | 129 | setLoading(false) 130 | } 131 | catch (error){ 132 | console.error('error retrieving traffic distribution group', error) 133 | setLoading(false) 134 | pushNotificationItem({ 135 | header: 'There was an error loading the traffic distribution group', 136 | content: error.message, 137 | type: 'error', 138 | dismissible: true, 139 | }) 140 | } 141 | } 142 | 143 | const submitTDGForm = async (formFields) => { 144 | console.log(formFields) 145 | confirmationModalRef.current.show('Update traffic distribution',['This will change the traffic distribution to your Contact Centers. It may take a few minutes to become effective.',
,
, 'Are you sure you want to continue?']) 146 | } 147 | 148 | const updateTD = async () => { 149 | console.debug('Update TD') 150 | setLoading(true) 151 | 152 | const td = tdg.TrafficDistribution 153 | const tdArn = td.Arn 154 | 155 | //Need to swap out the Id for ARN as that works in both regions. 156 | td.Id = tdArn 157 | 158 | // remove ARN as Update doesn't like it. 159 | delete td.Arn 160 | 161 | td.TelephonyConfig.Distributions.forEach((dist, index) => { 162 | if(index === 0) { 163 | dist.Percentage = region1Pct 164 | } else { 165 | dist.Percentage = region2Pct 166 | } 167 | }) 168 | 169 | try { 170 | const connectUpdateTrafficDistributionResult = await connectUpdateTrafficDistribution(td) 171 | console.log(connectUpdateTrafficDistributionResult) 172 | pushNotificationItem({ 173 | header: 'Successfully Updated traffic distribution', 174 | type: 'success', 175 | dismissible: true, 176 | }) 177 | await showTDG(tdArn) 178 | } catch (error) { 179 | pushNotificationItem({ 180 | header: 'There was an error updating the traffic distribution', 181 | content: error.message, 182 | type: 'error', 183 | dismissible: true, 184 | }) 185 | } 186 | 187 | setLoading(false) 188 | setEditTDModalVisible(false) 189 | } 190 | 191 | 192 | const handleRegion1PctChange = async (event) => { 193 | let region1Pct = event.target.value 194 | setRegion1SelectPct(pctOptions.find(o => o.value === region1Pct)) 195 | setRegion1Pct(region1Pct) 196 | setRegion2Pct(100 - region1Pct) 197 | } 198 | 199 | const editTDModal = ( 200 | setEditTDModalVisible(false)}> 201 |
204 | 205 | 206 | 207 | }> 208 | 209 | 210 | 219 | 220 | 221 |
222 | ) 223 | const handleManageNumbersClick = async () => { 224 | console.log('handleManageNumbersClick') 225 | await navigateToManageNumbers() 226 | } 227 | 228 | const editTDAction = ( 229 | 230 | 236 | 237 | ) 238 | 239 | return ( 240 |
241 | 242 | Traffic distribution group overview 243 | 244 | 245 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 288 | 289 | 299 | 300 | 301 | 302 | 303 | 304 | {loading && 305 | 306 | 307 | 308 | } 309 | 310 | {editTDModal} 311 | 312 |
313 | ) 314 | } 315 | 316 | export default TrafficDistributionGroup -------------------------------------------------------------------------------- /webapp/webpack.config.common.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 7 | const webpack = require("webpack"); 8 | 9 | module.exports = { 10 | entry: './src/index.js', 11 | output: { 12 | path: path.resolve(__dirname, 'build'), 13 | filename: 'index_bundle.js' 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.(js)$/, 19 | use: 'babel-loader', 20 | exclude: /node_modules/ 21 | }, 22 | { 23 | test: /\.css$/, 24 | use: ['style-loader', 'css-loader'] 25 | }, 26 | { 27 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 28 | type: 'asset/resource', 29 | }, 30 | ] 31 | }, 32 | plugins: [ 33 | new CleanWebpackPlugin({ verbose: false }), 34 | new HtmlWebpackPlugin({ 35 | template: 'src/index.html' 36 | }), 37 | new webpack.ProvidePlugin({ 38 | process: 'process/browser', 39 | }), 40 | ], 41 | resolve: { 42 | alias: { 43 | process: "process/browser" 44 | }, 45 | fallback: { 46 | "crypto": require.resolve("crypto-browserify"), 47 | "stream": require.resolve("stream-browserify") 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /webapp/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const { merge } = require('webpack-merge') 5 | const commonConfig = require('./webpack.config.common') 6 | 7 | module.exports = merge(commonConfig, { 8 | mode: 'development', 9 | devtool: 'source-map', 10 | output: { publicPath: '/' }, 11 | devServer: { 12 | static: './', 13 | open: true, 14 | historyApiFallback: true 15 | } 16 | }) -------------------------------------------------------------------------------- /webapp/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const { merge } = require('webpack-merge') 5 | const commonConfig = require('./webpack.config.common') 6 | 7 | module.exports = merge(commonConfig, { 8 | mode: 'production', 9 | output : { publicPath : '/'}, 10 | }) --------------------------------------------------------------------------------