diff --git a/.ds.baseline b/.ds.baseline new file mode 100644 index 0000000000..41cd12948b --- /dev/null +++ b/.ds.baseline @@ -0,0 +1,696 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".ds.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + ".github/workflows/checks.yml": [ + { + "type": "Secret Keyword", + "filename": ".github/workflows/checks.yml", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 65, + "is_secret": false + }, + { + "type": "Basic Auth Credentials", + "filename": ".github/workflows/checks.yml", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 99, + "is_secret": false + } + ], + "app/assets/js/uswds.min.js": [ + { + "type": "Secret Keyword", + "filename": "app/assets/js/uswds.min.js", + "hashed_secret": "372ea08cab33e71c02c651dbc83a474d32c676ea", + "is_verified": false, + "line_number": 85, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "app/assets/js/uswds.min.js", + "hashed_secret": "53e07a32bf191d6917ee6fd863f0b52632a86798", + "is_verified": false, + "line_number": 85, + "is_secret": false + } + ], + "app/config.py": [ + { + "type": "Secret Keyword", + "filename": "app/config.py", + "hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc", + "is_verified": false, + "line_number": 117, + "is_secret": false + } + ], + "app/main/_commonly_used_passwords.py": [ + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "82e19fa12aab7cfc718a002fc82c0f074bf070e7", + "is_verified": false, + "line_number": 123, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "a172ffc990129fe6f68b50f6037c54a1894ee3fd", + "is_verified": false, + "line_number": 240, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "4de69ee6b12b7fc91070873b71ba6e2929b90619", + "is_verified": false, + "line_number": 244, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "370194ff6e0f93a7432e16cc9badd9427e8b4e13", + "is_verified": false, + "line_number": 284, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "3dd635a808ddb6dd4b6731f7c409d53dd4b14df2", + "is_verified": false, + "line_number": 356, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "67a74306b06d0c01624fe0d0249a570f4d093747", + "is_verified": false, + "line_number": 374, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "61d6504733ca7757e259c644acd085c4dd471019", + "is_verified": false, + "line_number": 910, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "4ea872dfd7eefbde0036da7f0780826353dc7477", + "is_verified": false, + "line_number": 940, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "b214f706bb602c1cc2adc5c6165e73622305f4bb", + "is_verified": false, + "line_number": 1010, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "5cbabd43e49a1fedbbc3b86311aa6c8fe446abf9", + "is_verified": false, + "line_number": 1195, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "18ad10fd4a67f21fc07b1aa5046b410f6b2bedf1", + "is_verified": false, + "line_number": 1213, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "10470c3b4b1fed12c3baac014be15fac67c6e815", + "is_verified": false, + "line_number": 1263, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "65e1946c8f102eca8ba0af291f7c5e807516d94c", + "is_verified": false, + "line_number": 1346, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "0075df0a74c07ee295c98238c018401c9a80183b", + "is_verified": false, + "line_number": 1397, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "ca0023d7b345802fbc227b902cb9c57a3e02195f", + "is_verified": false, + "line_number": 1442, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "c8c6ca2e11c2dfd2a40914585b5944bffea15c8c", + "is_verified": false, + "line_number": 1555, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "b85b97a99eab8c809570c61d6404c1e49bdefbb4", + "is_verified": false, + "line_number": 1596, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "dec7dd342a499dfd4d283d872ccf598d8a7b6039", + "is_verified": false, + "line_number": 1789, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "2dc5053699a351121bf839c446bd4a878dda5735", + "is_verified": false, + "line_number": 1939, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "e5d54f0ac13abbdaa94b696c2469148b96dd11ab", + "is_verified": false, + "line_number": 2242, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "6059f42e2bbae78141e8a9e6286755ee691d5ce0", + "is_verified": false, + "line_number": 2305, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "fe703d258c7ef5f50b71e06565a65aa07194907f", + "is_verified": false, + "line_number": 2348, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "c229b68e1c3ffd9874838b5cb5354a0ee1367ddc", + "is_verified": false, + "line_number": 2349, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "756de479126e911b6f3400ae686d663d9d26b509", + "is_verified": false, + "line_number": 2920, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "6b174322afcdb440ee9cc3cc11eb16f9a00dec04", + "is_verified": false, + "line_number": 2975, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "9860783bfb510cbb2bf34471ec0b84a7ea587695", + "is_verified": false, + "line_number": 3359, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "b227cbd22eaa96019ebfc4aff35ad2add2a47439", + "is_verified": false, + "line_number": 3590, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "381d48209aecab8834eb495c5b5406100da07882", + "is_verified": false, + "line_number": 3811, + "is_secret": false + }, + { + "type": "Hex High Entropy String", + "filename": "app/main/_commonly_used_passwords.py", + "hashed_secret": "508b38590a90d32990aadd7350d160b795c3ab41", + "is_verified": false, + "line_number": 3850, + "is_secret": false + } + ], + "app/templates/new/components/head.html": [ + { + "type": "Base64 High Entropy String", + "filename": "app/templates/new/components/head.html", + "hashed_secret": "ee5048791fc7ff45a1545e24f85bec3317371327", + "is_verified": false, + "line_number": 33, + "is_secret": false + } + ], + "app/templates/old/admin_template.html": [ + { + "type": "Base64 High Entropy String", + "filename": "app/templates/old/admin_template.html", + "hashed_secret": "ee5048791fc7ff45a1545e24f85bec3317371327", + "is_verified": false, + "line_number": 18, + "is_secret": false + } + ], + "deploy-config/sandbox.yml": [ + { + "type": "Secret Keyword", + "filename": "deploy-config/sandbox.yml", + "hashed_secret": "113151dd10316fcb0d5507b6215d78e2f3fe9e54", + "is_verified": false, + "line_number": 8, + "is_secret": false + } + ], + "pytest.ini": [ + { + "type": "Secret Keyword", + "filename": "pytest.ini", + "hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc", + "is_verified": false, + "line_number": 7, + "is_secret": false + }, + { + "type": "Base64 High Entropy String", + "filename": "pytest.ini", + "hashed_secret": "d347784b1ab6074a65cda7bc42f1561bed85493f", + "is_verified": false, + "line_number": 7, + "is_secret": false + }, + { + "type": "Base64 High Entropy String", + "filename": "pytest.ini", + "hashed_secret": "ed1754d5cc82c8fd83205ebfb8c43fe4e88415a4", + "is_verified": false, + "line_number": 9, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "pytest.ini", + "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "is_verified": false, + "line_number": 11, + "is_secret": false + } + ], + "tests/__init__.py": [ + { + "type": "Secret Keyword", + "filename": "tests/__init__.py", + "hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea", + "is_verified": false, + "line_number": 388, + "is_secret": false + } + ], + "tests/app/main/forms/test_register_user_form.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/forms/test_register_user_form.py", + "hashed_secret": "8c6c978dc8e08771c7dea1ea2370fdf2446e5ba5", + "is_verified": false, + "line_number": 38, + "is_secret": false + } + ], + "tests/app/main/test_errorhandlers.py": [ + { + "type": "Base64 High Entropy String", + "filename": "tests/app/main/test_errorhandlers.py", + "hashed_secret": "005fa73b3f2be8f0d71d361c1f0a9d787cd09b4e", + "is_verified": false, + "line_number": 33, + "is_secret": false + } + ], + "tests/app/main/test_request_header.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/test_request_header.py", + "hashed_secret": "6866ef97a972ba3a2c6ff8bb2812981054770162", + "is_verified": false, + "line_number": 21, + "is_secret": false + } + ], + "tests/app/main/views/organizations/test_organization_invites.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/organizations/test_organization_invites.py", + "hashed_secret": "bdbb156d25d02fd7792865824201dda1c60f4473", + "is_verified": false, + "line_number": 274, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/organizations/test_organization_invites.py", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 282, + "is_secret": false + } + ], + "tests/app/main/views/test_accept_invite.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_accept_invite.py", + "hashed_secret": "07f0a6c13923fc3b5f0c57ffa2d29b715eb80d71", + "is_verified": false, + "line_number": 643, + "is_secret": false + } + ], + "tests/app/main/views/test_new_password.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_new_password.py", + "hashed_secret": "a41d5c3bbcd0b39c627b9cbf4897c6d25efa694f", + "is_verified": false, + "line_number": 89, + "is_secret": false + } + ], + "tests/app/main/views/test_register.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_register.py", + "hashed_secret": "bdbb156d25d02fd7792865824201dda1c60f4473", + "is_verified": false, + "line_number": 122, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_register.py", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 201, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_register.py", + "hashed_secret": "bb5b7caa27d005d38039e3797c3ddb9bcd22c3c8", + "is_verified": false, + "line_number": 274, + "is_secret": false + } + ], + "tests/app/main/views/test_sign_in.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_sign_in.py", + "hashed_secret": "8b8b69116ee882b5e987e330f55db81aba0636f9", + "is_verified": false, + "line_number": 97, + "is_secret": false + } + ], + "tests/app/main/views/test_two_factor.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_two_factor.py", + "hashed_secret": "dc66ad927c29e31c6c374231f57a4684b0687bfe", + "is_verified": false, + "line_number": 290, + "is_secret": false + } + ], + "tests/app/main/views/test_user_profile.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_user_profile.py", + "hashed_secret": "8072d7aad32964ec43fbcb699c75dc38890792f7", + "is_verified": false, + "line_number": 336, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_user_profile.py", + "hashed_secret": "4c9dbb972da179e4f66f023eaa5fb9451d835030", + "is_verified": false, + "line_number": 337, + "is_secret": false + } + ], + "tests/app/main/views/test_verify.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/main/views/test_verify.py", + "hashed_secret": "faafcfa63e128929409bf310b7ea5a415f2331ce", + "is_verified": false, + "line_number": 160, + "is_secret": false + } + ], + "tests/app/notify_client/test_user_client.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/notify_client/test_user_client.py", + "hashed_secret": "f2c57870308dc87f432e5912d4de6f8e322721ba", + "is_verified": false, + "line_number": 55, + "is_secret": false + } + ], + "tests/app/test_cloudfoundry_config.py": [ + { + "type": "Secret Keyword", + "filename": "tests/app/test_cloudfoundry_config.py", + "hashed_secret": "5e44dae2de8b6e57c797b968035265c9f2cd2b3e", + "is_verified": false, + "line_number": 12, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "tests/app/test_cloudfoundry_config.py", + "hashed_secret": "e5e178db7317356946d13e5d2da037d39ac61c71", + "is_verified": false, + "line_number": 27, + "is_secret": false + } + ], + "tests/conftest.py": [ + { + "type": "Secret Keyword", + "filename": "tests/conftest.py", + "hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea", + "is_verified": false, + "line_number": 3266, + "is_secret": false + } + ], + "tests/notifications_utils/clients/antivirus/test_antivirus_client.py": [ + { + "type": "Secret Keyword", + "filename": "tests/notifications_utils/clients/antivirus/test_antivirus_client.py", + "hashed_secret": "932b25270abe1301c22c709a19082dff07d469ff", + "is_verified": false, + "line_number": 16, + "is_secret": false + } + ], + "tests/notifications_utils/clients/encryption/test_encryption_client.py": [ + { + "type": "Secret Keyword", + "filename": "tests/notifications_utils/clients/encryption/test_encryption_client.py", + "hashed_secret": "f1e923a9667de11be6a210849a8651c1bfd81605", + "is_verified": false, + "line_number": 13, + "is_secret": false + } + ], + "tests/notifications_utils/clients/zendesk/test_zendesk_client.py": [ + { + "type": "Secret Keyword", + "filename": "tests/notifications_utils/clients/zendesk/test_zendesk_client.py", + "hashed_secret": "913a73b565c8e2c8ed94497580f619397709b8b6", + "is_verified": false, + "line_number": 16, + "is_secret": false + } + ] + }, + "generated_at": "2024-08-20T14:14:36Z" +} diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index b2d5829865..db1540fad5 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -9,10 +9,10 @@ runs: sudo apt-get update \ && sudo apt-get install -y --no-install-recommends \ libcurl4-openssl-dev - - name: Set up Python 3.12 + - name: Set up Python 3.12.3 uses: actions/setup-python@v4 with: - python-version: "3.12" + python-version: "3.12.3" - name: Install poetry shell: bash run: pip install poetry diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c3920a8b5c..7df8ef7f09 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,14 +3,22 @@ # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -version: 3 +version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "daily" - - package-ecosystem: 'npm' - directory: '/' + assignees: + - "A-Shumway42" + reviewers: + - "A-Shumway42" + - package-ecosystem: "npm" + directory: "/" schedule: - interval: 'daily' - + interval: "daily" + versioning-strategy: increase + assignees: + - "alexjanousekGSA" + reviewers: + - "alexjanousekGSA" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cb6b2c84b5..a87db3fcd0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,79 +1,22 @@ -*A note to PR reviewers: it may be helpful to review our -[code review documentation](https://github.com/GSA/notifications-api/blob/main/docs/all.md#code-reviews) -to know what to keep in mind while reviewing pull requests.* +*A note to PR reviewers: it may be helpful to review our [code review documentation](https://github.com/GSA/notifications-api/blob/main/docs/all.md#code-reviews) to know what to keep in mind while reviewing pull requests.* ## Description -Please enter a clear description about your proposed changes and what the -expected outcome(s) is/are from there. If there are complex implementation -details within the changes, this is a great place to explain those details using -plain language. - -This should include: - -- Links to issues that this PR addresses -- Screenshots or screen captures of any visible changes, especially for UI work -- Dependency changes - -If there are any caveats, known issues, follow-up items, etc., make a quick note -of them here as well, though more details are probably warranted in the issue -itself in this case. +Please enter a detailed description here. ## TODO (optional) -If you're opening a draft PR, it might be helpful to list any outstanding work, -especially if you're asking folks to take a look before it's ready for full -review. In this case, create a small checklist with the outstanding items: - -- [ ] TODO item 1 -- [ ] TODO item 2 -- [ ] TODO item ... +* [ ] TODO item 1 +* [ ] TODO item 2 +* [ ] TODO item ... ## Security Considerations -Please think about the security compliance aspect of your changes and what the -potential impacts might be. - -**NOTE: Please be mindful of sharing sensitive information here! If you're not -sure of what to write, please ask the team first before writing anything here.** - -Relevant details could include (and are not limited to) the following: - -- Handling secrets/credential management (or specifically calling out that there - is nothing to handle) -- Any adjustments to the flow of data in and out the system, or even within it -- Connecting or disconnecting any external services to the application -- Handling of any sensitive information, such as PII -- Handling of information within log statements or other application monitoring - services/hooks -- The inclusion of a new external dependency or the removal of an existing one -- ... (anything else relevant from a security compliance perspective) - -There are some cases where there are no security considerations to be had, e.g., -updating our documentation with publicly available information. In those cases -it is fine to simply put something like this: - -- None; this is a documentation update with publicly available information. +* Consideration 1 +* Consideration 2 +* Consideration ... diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f50ee1bcc8..3573d1a8a7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -23,6 +23,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.3.0" + - name: Install dependencies + run: npm install - uses: ./.github/actions/setup-project - uses: jwalton/gh-find-current-pr@v1 id: findPr @@ -38,8 +44,6 @@ jobs: run: poetry run isort --check-only ./app ./tests - name: Check dead code run: make dead-code - - name: Run js lint - run: npm run lint - name: Run js tests run: npm test - name: Run py tests with coverage @@ -48,6 +52,7 @@ jobs: run: poetry run coverage report --fail-under=90 end-to-end-tests: + if: ${{ github.actor != 'dependabot[bot]' }} permissions: checks: write pull-requests: write @@ -78,6 +83,7 @@ jobs: ports: # Maps tcp port 6379 on service container to the host - 6379:6379 + steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-project @@ -86,10 +92,10 @@ jobs: - name: Clone API uses: actions/checkout@v4 with: - repository: GSA/notifications-api - path: 'notifications-api' + repository: GSA/notifications-api + path: "notifications-api" - name: Install API dependencies - working-directory: 'notifications-api' + working-directory: "notifications-api" run: make bootstrap env: DATABASE_URL: postgresql://user:password@localhost:5432/test_notification_api @@ -99,7 +105,7 @@ jobs: NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }} NOTIFY_ENVIRONMENT: development - name: Run API server - working-directory: 'notifications-api' + working-directory: "notifications-api" run: make run-procfile & env: DATABASE_URL: postgresql://user:password@localhost:5432/test_notification_api @@ -130,6 +136,12 @@ jobs: # Debugging for now to troubleshoot a connectivity issue to the local servers # run: curl --request GET --url "http://localhost:6012" env: + API_HOST_NAME: http://localhost:6011 + DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }} + ADMIN_CLIENT_USERNAME: notify-admin + NOTIFY_ENVIRONMENT: e2etest NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }} NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }} @@ -160,6 +172,8 @@ jobs: - uses: pypa/gh-action-pip-audit@v1.0.8 with: inputs: requirements.txt + ignore-vulns: | + PYSEC-2024-60 - name: Run npm audit run: make npm-audit @@ -183,12 +197,12 @@ jobs: - name: Run OWASP Baseline Scan uses: zaproxy/action-baseline@v0.9.0 with: - docker_name: 'ghcr.io/zaproxy/zaproxy:weekly' - target: 'http://localhost:6012' + docker_name: "ghcr.io/zaproxy/zaproxy:weekly" + target: "http://localhost:6012" fail_action: true allow_issue_writing: false - rules_file_name: 'zap.conf' - cmd_options: '-I' + rules_file_name: "zap.conf" + cmd_options: "-I" a11y-scan: runs-on: ubuntu-20.04 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..d4d9a1328f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,95 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main", "production" ] + pull_request: + branches: [ "main", "production" ] + schedule: + - cron: '18 5 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index dc725f1573..89adc1f293 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -18,11 +18,11 @@ jobs: - name: Check for changes to Terraform id: changed-terraform-files - uses: tj-actions/changed-files@v41.0.0 + uses: tj-actions/changed-files@v44 with: files: | - terraform/demo - terraform/shared + terraform/demo/** + terraform/shared/** .github/workflows/deploy-demo.yml - name: Terraform init if: steps.changed-terraform-files.outputs.any_changed == 'true' @@ -88,7 +88,7 @@ jobs: - name: Check for changes to egress config id: changed-egress-config - uses: tj-actions/changed-files@v41.0.0 + uses: tj-actions/changed-files@v44 with: files: | deploy-config/egress_proxy/notify-admin-demo.*.acl diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index d614bf3091..262079be80 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -18,11 +18,11 @@ jobs: - name: Check for changes to Terraform id: changed-terraform-files - uses: tj-actions/changed-files@v41.0.0 + uses: tj-actions/changed-files@v44 with: files: | - terraform/production - terraform/shared + terraform/production/** + terraform/shared/** .github/workflows/deploy-prod.yml - name: Terraform init if: steps.changed-terraform-files.outputs.any_changed == 'true' @@ -88,7 +88,7 @@ jobs: - name: Check for changes to egress config id: changed-egress-config - uses: tj-actions/changed-files@v41.0.0 + uses: tj-actions/changed-files@v44 with: files: | deploy-config/egress_proxy/notify-admin-production.*.acl diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d74ba3133d..8cf33babc9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,96 +17,96 @@ jobs: environment: staging steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 + - uses: actions/checkout@v4 + with: + fetch-depth: 2 - - name: Check for changes to Terraform - id: changed-terraform-files - uses: tj-actions/changed-files@v41.0.0 - with: - files: | - terraform/staging - terraform/shared - .github/workflows/deploy.yml - - name: Terraform init - if: steps.changed-terraform-files.outputs.any_changed == 'true' - working-directory: terraform/staging - env: - AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} - run: terraform init - - name: Terraform apply - if: steps.changed-terraform-files.outputs.any_changed == 'true' - working-directory: terraform/staging - env: - AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} - TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }} - TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} - run: terraform apply -auto-approve -input=false + - name: Check for changes to Terraform + id: changed-terraform-files + uses: tj-actions/changed-files@v44 + with: + files: | + terraform/staging/** + terraform/shared/** + .github/workflows/deploy.yml + - name: Terraform init + if: steps.changed-terraform-files.outputs.any_changed == 'true' + working-directory: terraform/staging + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} + run: terraform init + - name: Terraform apply + if: steps.changed-terraform-files.outputs.any_changed == 'true' + working-directory: terraform/staging + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} + TF_VAR_cf_user: ${{ secrets.CLOUDGOV_USERNAME }} + TF_VAR_cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} + run: terraform apply -auto-approve -input=false - - uses: ./.github/actions/setup-project + - uses: ./.github/actions/setup-project - - name: Create requirements.txt - run: poetry export --without-hashes --format=requirements.txt > requirements.txt + - name: Create requirements.txt + run: poetry export --without-hashes --format=requirements.txt > requirements.txt - - name: Deploy to cloud.gov - uses: 18f/cg-deploy-action@main - env: - DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }} - SECRET_KEY: ${{ secrets.SECRET_KEY }} - ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }} - NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }} - NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }} - COMMIT_HASH: ${{ github.sha }} - LOGIN_PEM: ${{ secrets.LOGIN_PEM }} - LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov" - LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo" - LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token" - LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-staging.app.cloud.gov/sign-out" - LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?" - LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-staging.app.cloud.gov/sign-out" - LOGIN_DOT_GOV_INITIAL_SIGNIN_URL: "https://secure.login.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&nonce=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=STATEE" - with: - cf_username: ${{ secrets.CLOUDGOV_USERNAME }} - cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} - cf_org: gsa-tts-benefits-studio - cf_space: notify-staging - push_arguments: >- - --vars-file deploy-config/staging.yml - --var DANGEROUS_SALT="$DANGEROUS_SALT" - --var SECRET_KEY="$SECRET_KEY" - --var ADMIN_CLIENT_USERNAME="notify-admin" - --var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET" - --var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY" - --var NR_BROWSER_KEY="$NR_BROWSER_KEY" - --var COMMIT_HASH="$COMMIT_HASH" - --var LOGIN_PEM="$LOGIN_PEM" - --var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID" - --var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL" - --var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL" - --var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL" - --var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL" - --var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT" - --var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL" + - name: Deploy to cloud.gov + uses: 18f/cg-deploy-action@main + env: + DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }} + NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }} + NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }} + COMMIT_HASH: ${{ github.sha }} + LOGIN_PEM: ${{ secrets.LOGIN_PEM }} + LOGIN_DOT_GOV_CLIENT_ID: "urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov" + LOGIN_DOT_GOV_USER_INFO_URL: "https://secure.login.gov/api/openid_connect/userinfo" + LOGIN_DOT_GOV_ACCESS_TOKEN_URL: "https://secure.login.gov/api/openid_connect/token" + LOGIN_DOT_GOV_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&post_logout_redirect_uri=https://notify-staging.app.cloud.gov/sign-out" + LOGIN_DOT_GOV_BASE_LOGOUT_URL: "https://secure.login.gov/openid_connect/logout?" + LOGIN_DOT_GOV_SIGNOUT_REDIRECT: "https://notify-staging.app.cloud.gov/sign-out" + LOGIN_DOT_GOV_INITIAL_SIGNIN_URL: "https://secure.login.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&nonce=NONCE&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/sign-in&response_type=code&scope=openid+email&state=STATEE" + with: + cf_username: ${{ secrets.CLOUDGOV_USERNAME }} + cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} + cf_org: gsa-tts-benefits-studio + cf_space: notify-staging + push_arguments: >- + --vars-file deploy-config/staging.yml + --var DANGEROUS_SALT="$DANGEROUS_SALT" + --var SECRET_KEY="$SECRET_KEY" + --var ADMIN_CLIENT_USERNAME="notify-admin" + --var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET" + --var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY" + --var NR_BROWSER_KEY="$NR_BROWSER_KEY" + --var COMMIT_HASH="$COMMIT_HASH" + --var LOGIN_PEM="$LOGIN_PEM" + --var LOGIN_DOT_GOV_CLIENT_ID="$LOGIN_DOT_GOV_CLIENT_ID" + --var LOGIN_DOT_GOV_USER_INFO_URL="$LOGIN_DOT_GOV_USER_INFO_URL" + --var LOGIN_DOT_GOV_ACCESS_TOKEN_URL="$LOGIN_DOT_GOV_ACCESS_TOKEN_URL" + --var LOGIN_DOT_GOV_LOGOUT_URL="$LOGIN_DOT_GOV_LOGOUT_URL" + --var LOGIN_DOT_GOV_BASE_LOGOUT_URL="$LOGIN_DOT_GOV_BASE_LOGOUT_URL" + --var LOGIN_DOT_GOV_SIGNOUT_REDIRECT="$LOGIN_DOT_GOV_SIGNOUT_REDIRECT" + --var LOGIN_DOT_GOV_INITIAL_SIGNIN_URL="$LOGIN_DOT_GOV_INITIAL_SIGNIN_URL" - - name: Check for changes to egress config - id: changed-egress-config - uses: tj-actions/changed-files@v41.0.0 - with: - files: | - deploy-config/egress_proxy/notify-admin-staging.*.acl - .github/actions/deploy-proxy/action.yml - .github/workflows/deploy.yml - - name: Deploy egress proxy - if: steps.changed-egress-config.outputs.any_changed == 'true' - uses: ./.github/actions/deploy-proxy - with: - cf_space: notify-staging - app: notify-admin-staging + - name: Check for changes to egress config + id: changed-egress-config + uses: tj-actions/changed-files@v44 + with: + files: | + deploy-config/egress_proxy/notify-admin-staging.*.acl + .github/actions/deploy-proxy/action.yml + .github/workflows/deploy.yml + - name: Deploy egress proxy + if: steps.changed-egress-config.outputs.any_changed == 'true' + uses: ./.github/actions/deploy-proxy + with: + cf_space: notify-staging + app: notify-admin-staging bail: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 57a363cd0a..d0a03e6f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,8 @@ app/templates/vendor secrets.auto.tfvars terraform.tfstate terraform.tfstate.backup +requirements.txt +terraform/sandbox/requirements.txt # Playwright playwright/ diff --git a/.nvmrc b/.nvmrc index d9289897d3..8326e27f9b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.15.1 +22.3.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 057c1ec169..cb3c48cae9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -11,3 +11,14 @@ repos: - id: debug-statements - id: check-merge-conflict - id: check-toml + - id: check-ast + - id: fix-byte-order-marker + - id: detect-aws-credentials + args: [--allow-missing-credentials] + - id: detect-private-key + - id: mixed-line-ending +- repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.ds.baseline'] diff --git a/Makefile b/Makefile index 7085bee586..90e8e76de5 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,8 @@ bootstrap: generate-version-file ## Set up everything to run the app poetry install --sync --no-root poetry run playwright install --with-deps poetry run pre-commit install - source $(NVMSH) --no-use && nvm install && npm ci --no-audit + source $(NVMSH) --no-use && nvm install && npm install + source $(NVMSH) && npm ci --no-audit source $(NVMSH) && npm run build .PHONY: watch-frontend @@ -40,6 +41,10 @@ run-flask-bare: ## Run flask without invoking poetry so we can override ENV var npm-audit: ## Check for vulnerabilities in NPM packages source $(NVMSH) && npm run audit +.PHONY: npm-audit-fix +npm-audit-fix: ## Fix vulnerabilities that do not require attentino (according to npm) + source $(NVMSH) && npm audit fix + .PHONY: help help: @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -71,13 +76,14 @@ too-complex: .PHONY: py-test py-test: export NEW_RELIC_ENVIRONMENT=test py-test: ## Run python unit tests - poetry run coverage run --omit=*/notifications_utils/* -m pytest --maxfail=10 --ignore=tests/end_to_end tests/ + poetry run coverage run -m pytest --maxfail=10 --ignore=tests/end_to_end tests/ poetry run coverage report --fail-under=96 poetry run coverage html -d .coverage_cache .PHONY: dead-code -dead-code: - poetry run vulture ./app --min-confidence=100 +dead-code: ## 60% is our aspirational goal, but currently breaks the build + poetry run vulture ./app ./notifications_utils --min-confidence=100 + .PHONY: e2e-test e2e-test: export NEW_RELIC_ENVIRONMENT=test @@ -101,13 +107,6 @@ py-lock: ## Syncs dependencies and updates lock file without performing recursiv poetry lock --no-update poetry install --sync -.PHONY: update-utils -update-utils: ## Forces Poetry to pull the latest changes from the notifications-utils repo; requires that you commit the changes to poetry.lock! - poetry update notifications-utils - @echo - @echo !!! PLEASE MAKE SURE TO COMMIT AND PUSH THE UPDATED poetry.lock FILE !!! - @echo - .PHONY: freeze-requirements freeze-requirements: ## create static requirements.txt poetry export --without-hashes --format=requirements.txt > requirements.txt diff --git a/README.md b/README.md index f907dca88b..04458e394a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ UI's backend and is required for most things to function. Set that up first! Our other repositories are: - [notifications-admin](https://github.com/GSA/notifications-admin) -- [notifications-utils](https://github.com/GSA/notifications-utils) - [us-notify-compliance](https://github.com/GSA/us-notify-compliance/) - [notify-python-demo](https://github.com/GSA/notify-python-demo) @@ -41,7 +40,7 @@ You will need the following items: [Follow the instructions here to set up the Notify.gov API.](https://github.com/GSA/notifications-api#before-you-start) The Notify.gov API is required in order for the Notify.gov Admin UI to run, and -it will also take care of many of the steps that are listed here. The sections +it will also take care of many of the steps that are listed here. The sections that are a repeat from the API setup are flagged with an **[API Step]** label in front of them. @@ -49,7 +48,7 @@ in front of them. This project is set up to work with [nvm (Node Version Manager)](https://github.com/nvm-sh/nvm#installing-and-updating) -for managing and using Node.js (version 16.15.1) and the `npm` package manager. +for managing and using Node.js (version 22.3.0) and the `npm` package manager. These instructions will walk you through how to set your machine up with all of the required tools for this project. @@ -84,11 +83,13 @@ Your system `$PATH` environment variable is likely set in one of these locations: For BASH shells: + - `~/.bashrc` - `~/.bash_profile` - `~/.profile` For ZSH shells: + - `~/.zshrc` - `~/.zprofile` @@ -98,7 +99,7 @@ environments. Which file you need to modify depends on whether or not you are running an interactive shell or a login shell (see [this Stack Overflow post](https://stackoverflow.com/questions/18186929/what-are-the-differences-between-a-login-shell-and-interactive-shell) -for an explanation of the differences). If you're still not sure, please ask +for an explanation of the differences). If you're still not sure, please ask the team for help! Once you determine which file you'll need to modify, add these lines before any @@ -159,7 +160,7 @@ _NOTE: This project currently uses the latest `1.4.x release of Terraform._ #### [API Step] Python Installation Now we're going to install a tool to help us manage Python versions and -virtual environments on our system. First, we'll install +virtual environments on our system. First, we'll install [pyenv](https://github.com/pyenv/pyenv) and one of its plugins, [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv), with Homebrew: @@ -286,7 +287,7 @@ we'll use `3.12` in our example here since we recently upgraded to this version: pyenv install 3.12 ``` -Next, delete the virtual environment you previously had set up. If you followed +Next, delete the virtual environment you previously had set up. If you followed the instructions above with the first-time set up, you can do this with `pyenv`: ```sh @@ -307,6 +308,20 @@ you'll be set with an upgraded version of Python. _If you're not sure about the details of your current virtual environment, you can run `poetry env info` to get more information. If you've been using `pyenv` for everything, you can also see all available virtual environments with `pyenv virtualenvs`._ +#### Updating the .env file for Login.gov + +To configure the application for Login.gov, you will need to update the following environment variables in the .env file: + +``` +COMMIT_HASH=”--------” +``` + +Reach out to someone on the team to get the most recent Login.gov key. + +``` +LOGIN_PEM="INSERT_LOGIN_GOV_KEY_HERE" +``` + #### Updating the .env file for E2E tests With the newly created `.env` file in place, you'll need to make one more @@ -351,6 +366,28 @@ This will run the local development web server and make the admin site available at http://localhost:6012; remember to make sure that the Notify.gov API is running as well! +## Creating a 'First User' in the database + +After you have completed all setup steps, you will be unable to log in, because there +will not be a user in the database to link to the login.gov account you are using. So +you will need to create that user in your database using the 'create-test-user' command. + +Open two terminals pointing to the api project and then run these commands in the +respective terminals. + +(Server 1) +env ALLOW_EXPIRED_API_TOKEN=1 make run-flask + +(Server 2) +poetry run flask command create-admin-jwt | tail -n 1 | pbcopy +poetry run flask command create-test-user --admin=True; + +Supply your name, email address, mobile number, and password when prompted. Make sure the email address +is the same one you are using in login.gov and make sure your phone number is in the format 5555555555. + +If for any reason in the course of development it is necessary for your to delete your db +via the `dropdb` command, you will need to repeat these steps when you recreate your db. + ## Git Hooks We're using [`pre-commit`](https://pre-commit.com/) to manage hooks in order to @@ -396,23 +433,6 @@ In either situation, once you are finished and have verified the dependency changes are working, please be sure to commit both the `pyproject.toml` and `poetry.lock` files. -### Keeping the notification-utils Dependency Up-to-Date - -The `notifications-utils` dependency references the other repository we have at -https://github.com/GSA/notifications-utils - this dependency requires a bit of -extra legwork to ensure it stays up-to-date. - -Whenever a PR is merged in the `notifications-utils` repository, we need to make -sure the changes are pulled in here and committed to this repository as well. -You can do this by going through these steps: - -- Make sure your local `main` branch is up-to-date -- Create a new branch to work in -- Run `make update-utils` -- Commit the updated `poetry.lock` file and push the changes -- Make a new PR with the change -- Have the PR get reviewed and merged - ## Known Installation Issues ### Python Installation Errors diff --git a/app/__init__.py b/app/__init__.py index d2c61d0a06..483d89ea06 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,12 +23,6 @@ from flask_wtf.csrf import CSRFError from itsdangerous import BadSignature from notifications_python_client.errors import HTTPError -from notifications_utils import logging, request_helper -from notifications_utils.formatters import ( - formatted_list, - get_lines_with_normalised_whitespace, -) -from notifications_utils.recipients import format_phone_number_human_readable from werkzeug.exceptions import HTTPException as WerkzeugHTTPException from werkzeug.exceptions import abort from werkzeug.local import LocalProxy @@ -36,7 +30,7 @@ from app import proxy_fix from app.asset_fingerprinter import asset_fingerprinter from app.config import configs -from app.extensions import redis_client, zendesk_client +from app.extensions import redis_client from app.formatters import ( convert_markdown_template, convert_to_boolean, @@ -114,12 +108,17 @@ from app.notify_client.user_api_client import user_api_client from app.url_converters import SimpleDateTypeConverter, TemplateTypeConverter from app.utils.govuk_frontend_jinja.flask_ext import init_govuk_frontend +from notifications_utils import logging, request_helper +from notifications_utils.formatters import ( + formatted_list, + get_lines_with_normalised_whitespace, +) +from notifications_utils.recipients import format_phone_number_human_readable login_manager = LoginManager() csrf = CSRFProtect() talisman = Talisman() - # The current service attached to the request stack. current_service = LocalProxy(partial(getattr, request_ctx, "service")) @@ -202,7 +201,6 @@ def create_app(application): user_api_client, # External API clients redis_client, - zendesk_client, ): client.init_app(application) @@ -231,6 +229,24 @@ def create_app(application): ) logging.init_app(application) + # Hopefully will help identify if there is a race condition causing the CSRF errors + # that we have occasionally seen in our environments. + for key in ("SECRET_KEY", "DANGEROUS_SALT"): + try: + value = application.config[key] + except KeyError: + application.logger.error(f"Env Var {key} doesn't exist.") + else: + try: + data_len = len(value.strip()) + except (TypeError, AttributeError): + application.logger.error(f"Env Var {key} invalid type: {type(value)}") + else: + if data_len: + application.logger.info(f"Env Var {key} is a non-zero length.") + else: + application.logger.error(f"Env Var {key} is empty.") + login_manager.login_view = "main.sign_in" login_manager.login_message_category = "default" login_manager.session_protection = None @@ -348,11 +364,23 @@ def make_session_permanent(): def create_beta_url(url): - url_created = urlparse(url) - url_list = list(url_created) - url_list[1] = "beta.notify.gov" - url_for_redirect = urlunparse(url_list) - return url_for_redirect + url_created = None + try: + url_created = urlparse(url) + url_list = list(url_created) + url_list[1] = "beta.notify.gov" + url_for_redirect = urlunparse(url_list) + return url_for_redirect + except ValueError: + # This might be happening due to IPv6, see issue # 1395. + # If we see "'RequestContext' object has no attribute 'service'" in the logs + # we can search around that timestamp and find this output, hopefully. + # It may be sufficient to just catch and log, and prevent the stack trace from being in the logs + # but we need to confirm the root cause first. + current_app.logger.error( + f"create_beta_url orig_url: {url} \ + url_created = {str(url_created)} url_for_redirect {str(url_for_redirect)}" + ) def redirect_notify_to_beta(): @@ -360,6 +388,7 @@ def redirect_notify_to_beta(): current_app.config["NOTIFY_ENVIRONMENT"] == "production" and "beta.notify.gov" not in request.url ): + # TODO add debug here to trace what is going on with the URL for the 'RequestContext' error url_to_beta = create_beta_url(request.url) return redirect(url_to_beta, 302) diff --git a/app/assets/images/gsa-logo.svg b/app/assets/images/gsa-logo.svg new file mode 100644 index 0000000000..fc05aecd23 --- /dev/null +++ b/app/assets/images/gsa-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/assets/images/logo-login.svg b/app/assets/images/logo-login.svg new file mode 100644 index 0000000000..0197133775 --- /dev/null +++ b/app/assets/images/logo-login.svg @@ -0,0 +1 @@ + diff --git a/app/assets/javascripts/activityChart.js b/app/assets/javascripts/activityChart.js new file mode 100644 index 0000000000..da9c65e388 --- /dev/null +++ b/app/assets/javascripts/activityChart.js @@ -0,0 +1,300 @@ +(function (window) { + + if (document.getElementById('activityChartContainer')) { + + const COLORS = { + delivered: '#0076d6', + failed: '#fa9441', + text: '#666' + }; + + const FONT_SIZE = 16; + const FONT_WEIGHT = 'bold'; + const MAX_Y = 120; + + const createChart = function(containerId, labels, deliveredData, failedData) { + const container = d3.select(containerId); + container.selectAll('*').remove(); // Clear any existing content + + const margin = { top: 60, right: 20, bottom: 40, left: 20 }; // Adjusted top margin for legend + const width = container.node().getBoundingClientRect().width - margin.left - margin.right; + const height = 400 - margin.top - margin.bottom; + + const svg = container.append('svg') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + let tooltip = d3.select('#tooltip'); + + if (tooltip.empty()) { + tooltip = d3.select('body').append('div') + .attr('id', 'tooltip') + .style('display', 'none'); + } + // Create legend + const legendContainer = d3.select('.chart-legend'); + legendContainer.selectAll('*').remove(); // Clear any existing legend + + const legendData = [ + { label: 'Delivered', color: COLORS.delivered }, + { label: 'Failed', color: COLORS.failed } + ]; + + const legendItem = legendContainer.selectAll('.legend-item') + .data(legendData) + .enter() + .append('div') + .attr('class', 'legend-item'); + + legendItem.append('div') + .attr('class', 'legend-rect') + .style('background-color', d => d.color) + .style('display', 'inline-block') + .style('margin-right', '5px'); + + legendItem.append('span') + .attr('class', 'legend-label') + .text(d => d.label); + + const x = d3.scaleBand() + .domain(labels) + .range([0, width]) + .padding(0.1); + // Adjust the y-axis domain to add some space above the tallest bar + const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0))); + const y = d3.scaleLinear() + .domain([0, maxY + 2]) // Add 2 units of space at the top + .nice() + .range([height, 0]); + + svg.append('g') + .attr('class', 'x axis') + .attr('transform', `translate(0,${height})`) + .call(d3.axisBottom(x)); + + // Generate the y-axis with whole numbers + const yAxis = d3.axisLeft(y) + .ticks(Math.min(maxY + 2, 10)) // Generate up to 10 ticks based on the data + .tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis + + svg.append('g') + .attr('class', 'y axis') + .call(yAxis); + + // Data for stacking + const stackData = labels.map((label, i) => ({ + label: label, + delivered: deliveredData[i], + failed: failedData[i] || 0 // Ensure there's a value for failed, even if it's 0 + })); + + // Stack the data + const stack = d3.stack() + .keys(['delivered', 'failed']) + .order(d3.stackOrderNone) + .offset(d3.stackOffsetNone); + + const series = stack(stackData); + + // Color scale + const color = d3.scaleOrdinal() + .domain(['delivered', 'failed']) + .range([COLORS.delivered, COLORS.failed]); + + // Create bars with animation + const barGroups = svg.selectAll('.bar-group') + .data(series) + .enter() + .append('g') + .attr('class', 'bar-group') + .attr('fill', d => color(d.key)); + + barGroups.selectAll('rect') + .data(d => d) + .enter() + .append('rect') + .attr('x', d => x(d.data.label)) + .attr('y', height) + .attr('height', 0) + .attr('width', x.bandwidth()) + .on('mouseover', function(event, d) { + const key = d3.select(this.parentNode).datum().key; + const capitalizedKey = key.charAt(0).toUpperCase() + key.slice(1); + tooltip.style('display', 'block') + .html(`${d.data.label}
${capitalizedKey}: ${d.data[key]}`); + }) + .on('mousemove', function(event) { + tooltip.style('left', `${event.pageX + 10}px`) + .style('top', `${event.pageY - 20}px`); + }) + .on('mouseout', function() { + tooltip.style('display', 'none'); + }) + .transition() + .duration(1000) + .attr('y', d => y(d[1])) + .attr('height', d => y(d[0]) - y(d[1])); + }; + + // Function to create an accessible table + const createTable = function(tableId, chartType, labels, deliveredData, failedData) { + const table = document.getElementById(tableId); + table.innerHTML = ""; // Clear previous data + + const captionText = document.querySelector(`#${chartType} .chart-subtitle`).textContent; + const caption = document.createElement('caption'); + caption.textContent = captionText; + const thead = document.createElement('thead'); + const tbody = document.createElement('tbody'); + + // Create table header + const headerRow = document.createElement('tr'); + const headers = ['Day', 'Delivered', 'Failed']; + headers.forEach(headerText => { + const th = document.createElement('th'); + th.textContent = headerText; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + + // Create table body + labels.forEach((label, index) => { + const row = document.createElement('tr'); + const cellDay = document.createElement('td'); + cellDay.textContent = label; + row.appendChild(cellDay); + + const cellDelivered = document.createElement('td'); + cellDelivered.textContent = deliveredData[index]; + row.appendChild(cellDelivered); + + const cellFailed = document.createElement('td'); + cellFailed.textContent = failedData[index]; + row.appendChild(cellFailed); + + tbody.appendChild(row); + }); + + table.appendChild(caption); + table.appendChild(thead); + table.append(tbody); + }; + + const fetchData = function(type) { + var ctx = document.getElementById('weeklyChart'); + if (!ctx) { + return; + } + + var url = type === 'service' ? `/daily_stats.json` : `/daily_stats_by_user.json`; + return fetch(url) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + labels = []; + deliveredData = []; + failedData = []; + + let totalMessages = 0; + + for (var dateString in data) { + if (data.hasOwnProperty(dateString)) { + const dateParts = dateString.split('-'); + const formattedDate = `${dateParts[1]}/${dateParts[2]}/${dateParts[0].slice(2)}`; + + labels.push(formattedDate); + deliveredData.push(data[dateString].sms.delivered); + failedData.push(data[dateString].sms.failure); + + // Calculate the total number of messages + totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure; + } + } + + // Check if there are no messages sent + const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`); + if (totalMessages === 0) { + // Remove existing chart and render the alert message + d3.select('#weeklyChart').selectAll('*').remove(); + d3.select('#weeklyChart') + .append('div') + .html(` +
+
+

+ No messages sent in the last 7 days +

+
+
+ `); + // Hide the subtitle + if (subTitle) { + subTitle.style.display = 'none'; + } + } else { + // If there are messages, create the chart and table + createChart('#weeklyChart', labels, deliveredData, failedData); + createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); + } + + return data; + }) + .catch(error => console.error('Error fetching daily stats:', error)); + }; + + const handleDropdownChange = function(event) { + const selectedValue = event.target.value; + const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`); + const selectElement = document.getElementById('options'); + const selectedText = selectElement.options[selectElement.selectedIndex].text; + + subTitle.textContent = `${selectedText} - last 7 days`; + fetchData(selectedValue); + + // Update ARIA live region + const liveRegion = document.getElementById('aria-live-account'); + liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`; + + // Switch tables based on dropdown selection + const selectedTable = selectedValue === "individual" ? "table1" : "table2"; + const tables = document.querySelectorAll('.table-overflow-x-auto'); + tables.forEach(function(table) { + table.classList.add('hidden'); // Hide all tables by adding the hidden class + table.classList.remove('visible'); // Ensure they are not visible + }); + const tableToShow = document.getElementById(selectedTable); + tableToShow.classList.remove('hidden'); // Remove hidden class + tableToShow.classList.add('visible'); // Add visible class + }; + + document.addEventListener('DOMContentLoaded', function() { + // Initialize activityChart chart and table with service data by default + fetchData('service'); + + // Add event listener to the dropdown + const dropdown = document.getElementById('options'); + dropdown.addEventListener('change', handleDropdownChange); + }); + + // Resize chart on window resize + window.addEventListener('resize', function() { + if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) { + createChart('#weeklyChart', labels, deliveredData, failedData); + createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); + } + }); + + // Export functions for testing + window.createChart = createChart; + window.createTable = createTable; + window.handleDropdownChange = handleDropdownChange; + window.fetchData = fetchData; + } + +})(window); diff --git a/app/assets/javascripts/totalMessagesChart.js b/app/assets/javascripts/totalMessagesChart.js new file mode 100644 index 0000000000..a3c10d7a36 --- /dev/null +++ b/app/assets/javascripts/totalMessagesChart.js @@ -0,0 +1,150 @@ +(function (window) { + function createTotalMessagesChart() { + var chartContainer = document.getElementById('totalMessageChartContainer'); + if (!chartContainer) return; + + var chartTitle = document.getElementById('chartTitle').textContent; + + // Access data attributes from the HTML + var sms_sent = parseInt(chartContainer.getAttribute('data-sms-sent')); + var sms_remaining_messages = parseInt(chartContainer.getAttribute('data-sms-allowance-remaining')); + var totalMessages = sms_sent + sms_remaining_messages; + + // Update the message below the chart + document.getElementById('message').innerText = `${sms_sent.toLocaleString()} sent / ${sms_remaining_messages.toLocaleString()} remaining`; + + // Calculate minimum width for "Messages Sent" as 1% of the total chart width + var minSentPercentage = 0.02; // Minimum width as a percentage of total messages (1% in this case) + var minSentValue = totalMessages * minSentPercentage; + var displaySent = Math.max(sms_sent, minSentValue); + var displayRemaining = totalMessages - displaySent; + + var svg = d3.select("#totalMessageChart"); + var width = chartContainer.clientWidth; + var height = 48; + + // Ensure the width is set correctly + if (width === 0) { + console.error('Chart container width is 0, cannot set SVG width.'); + return; + } + + svg.attr("width", width).attr("height", height); + + var x = d3.scaleLinear() + .domain([0, totalMessages]) + .range([0, width]); + + // Create tooltip dynamically + var tooltip = d3.select("body").append("div") + .attr("id", "tooltip"); + + // Create the initial bars + var sentBar = svg.append("rect") + .attr("x", 0) + .attr("y", 0) + .attr("height", height) + .attr("fill", '#0076d6') + .attr("width", 0) // Start with width 0 for animation + .on('mouseover', function(event) { + tooltip.style('display', 'block') + .html(`Messages Sent: ${sms_sent.toLocaleString()}`); + }) + .on('mousemove', function(event) { + tooltip.style('left', `${event.pageX + 10}px`) + .style('top', `${event.pageY - 20}px`); + }) + .on('mouseout', function() { + tooltip.style('display', 'none'); + }); + + var remainingBar = svg.append("rect") + .attr("x", 0) // Initially set to 0, will be updated during animation + .attr("y", 0) + .attr("height", height) + .attr("fill", '#C7CACE') + .attr("width", 0) // Start with width 0 for animation + .on('mouseover', function(event) { + tooltip.style('display', 'block') + .html(`Remaining: ${sms_remaining_messages.toLocaleString()}`); + }) + .on('mousemove', function(event) { + tooltip.style('left', `${event.pageX + 10}px`) + .style('top', `${event.pageY - 20}px`); + }) + .on('mouseout', function() { + tooltip.style('display', 'none'); + }); + + // Animate the bars together as a single cohesive line + svg.transition() + .duration(1000) // Total animation duration + .attr("width", width) + .tween("resize", function() { + var interpolator = d3.interpolate(0, width); + return function(t) { + var newWidth = interpolator(t); + var sentWidth = x(displaySent) / width * newWidth; + var remainingWidth = x(displayRemaining) / width * newWidth; + sentBar.attr("width", sentWidth); + remainingBar.attr("x", sentWidth).attr("width", remainingWidth); + }; + }); + + // Create and populate the accessible table + var tableContainer = document.getElementById('totalMessageTable'); + var table = document.createElement('table'); + table.className = 'usa-sr-only usa-table'; + + var caption = document.createElement('caption'); + caption.textContent = chartTitle; + table.appendChild(caption); + + var thead = document.createElement('thead'); // Ensure thead is created + var theadRow = document.createElement('tr'); + var thMessagesSent = document.createElement('th'); + thMessagesSent.textContent = 'Messages Sent'; // First column header + var thRemaining = document.createElement('th'); + thRemaining.textContent = 'Remaining'; // Second column header + theadRow.appendChild(thMessagesSent); + theadRow.appendChild(thRemaining); + thead.appendChild(theadRow); // Append theadRow to the thead + table.appendChild(thead); + + var tbody = document.createElement('tbody'); + var tbodyRow = document.createElement('tr'); + + var tdMessagesSent = document.createElement('td'); + tdMessagesSent.textContent = sms_sent.toLocaleString(); // Value for Messages Sent + var tdRemaining = document.createElement('td'); + tdRemaining.textContent = sms_remaining_messages.toLocaleString(); // Value for Remaining + + tbodyRow.appendChild(tdMessagesSent); + tbodyRow.appendChild(tdRemaining); + tbody.appendChild(tbodyRow); + + table.appendChild(tbody); + tableContainer.appendChild(table); + + table.appendChild(tbody); + tableContainer.appendChild(table); + + // Ensure the chart resizes correctly on window resize + window.addEventListener('resize', function () { + width = chartContainer.clientWidth; + x.range([0, width]); + svg.attr("width", width); + sentBar.attr("width", x(displaySent)); + remainingBar.attr("x", x(displaySent)).attr("width", x(displayRemaining)); + }); + } + + // Initialize total messages chart if the container exists + document.addEventListener('DOMContentLoaded', function() { + createTotalMessagesChart(); + }); + + // Export function for testing + window.createTotalMessagesChart = createTotalMessagesChart; + +})(window); diff --git a/app/assets/sass/uswds/_data-visualization.scss b/app/assets/sass/uswds/_data-visualization.scss new file mode 100644 index 0000000000..e479334a30 --- /dev/null +++ b/app/assets/sass/uswds/_data-visualization.scss @@ -0,0 +1,159 @@ +@use "uswds-core" as *; + +$delivered: color('blue-50v'); +$pending: color('green-cool-40v'); +$failed: color('gray-cool-20'); + +.chart-container { + display: flex; + &.usage { + height: units(4); + } + svg { + overflow: visible; + } +} + +#totalMessageChartContainer { + max-width: 600px; +} + +.bar { + border-radius: units(0.5); + &.delivered, &.usage { + background-color: $delivered; + margin-right: 1px; + } + &.pending{ + background-color: $pending; + margin-right: 1px; + } + &.failed, &.remaining { + background-color: $failed; + } +} + +.legend { + display: flex; + margin: units(1) 0; + .legend-item { + display: flex; + align-items: flex-start; + margin-right: units(2); + } + .legend-color { + width: units(3); + height: units(3); + margin-right: 0; + padding: 0; + border-radius: 2px; + background-color: $delivered; + &.pending { + background-color: $pending; + } + &.failed, &.remaining { + background-color: $failed; + } + } + .legend-value { + margin: 0 units(1); + } +} + +.usa-tooltip { + line-height: 1; + .usa-tooltip__body { + width: units(mobile); + font-size: size("body", 2); + height: auto; + white-space: wrap; + line-height: units(1); + } +} + +.progress-bar { + width: 300px; + height: 20px; + background-color: #eee; + border-radius: 5px; + margin-bottom: 10px; +} + +.progress-bar-inner { + height: 100%; + background-color: #007bff; + border-radius: inherit; +} + +.chart { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: units(1) units(1) 0; +} + +.chart-subtitle { + font-size: size("body", 6); + font-weight: bold; + text-align: left; + width: 100%; + padding: 0; + margin: 0; +} + +.axis text { + font-size: size("body", 1); +} + +.axis line, +.axis path { + shape-rendering: crispEdges; + stroke: #000; + fill: none; +} + +.bar { + fill-opacity: 0.8; +} + +.chart-container { + width: 100%; + position: relative; +} + +.chart-legend { + display: flex; + align-items: center; +} + +.legend-item { + display: flex; + align-items: center; + margin-right: units(2); + .legend-rect { + width: units(2); + height: units(2); + margin-right: units(1); + } +} + +#tooltip { + position: absolute; + display: none; + background: color('ink'); + color: #FFF; + border: 1px solid #ccc; + padding: units(1); + border-radius: units(1); + pointer-events: none; + z-index: 100; + font-size: size("body", 3); + line-height: 1.3; +} diff --git a/app/assets/sass/uswds/_legacy-styles.scss b/app/assets/sass/uswds/_legacy-styles.scss index 0f16e88617..85da173213 100644 --- a/app/assets/sass/uswds/_legacy-styles.scss +++ b/app/assets/sass/uswds/_legacy-styles.scss @@ -66,6 +66,10 @@ h2.sms-message-header { margin-bottom: 0.5rem; } +.usa-prose >*+ h2.message-header { + margin-top: 1em; +} + h2.recipient-list { margin-bottom: 0.5rem; } @@ -340,11 +344,3 @@ h2.recipient-list { } } } - -.js-focus-style { - outline: 3px solid color("blue-40v"); - box-shadow: 0 0 0 7px color("blue-40v"); - *:focus { - outline: none; - } -} diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss index d66e276bdb..61cd543ef8 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -22,9 +22,8 @@ i.e. @use "uswds-core" as *; -iframe:focus, [href]:focus, [tabindex]:focus, [contentEditable=true]:focus { - outline: units(1px) dotted color('blue-40v'); - outline: units(1px) auto color('blue-40v'); +iframe:focus, [href]:focus, [tabindex]:focus, [contentEditable=true]:focus, button:not([disabled]):focus { + outline: units(2px) solid color('blue-40v'); outline-offset: 0.3rem; } @@ -157,6 +156,18 @@ td.table-empty-message { } } +.usa-button img { + margin-left: .5rem; + height: 1rem; +} + +.usa-button.login-button.login-button--primary,.login-button.login-button--primary:hover{ + color:#112e51;background-color:#fff; + border:1px solid #767676; + display: inline-flex; + justify-content: center; +} + .user-list-edit-link:active:before, .user-list-edit-link:focus:before { box-shadow: none; @@ -265,6 +276,12 @@ td.table-empty-message { display: block; } +.usa-table { + th { + border-bottom: 0 !important; + } +} + .js-stick-at-bottom-when-scrolling { display: flex; align-items: flex-end; @@ -315,6 +332,22 @@ td.table-empty-message { bottom: 0; } +.table-overflow-x-auto { + &.hidden { + display: none; + } +} + +@media (max-width: units('desktop-lg')) { + .table-overflow-x-auto { + overflow-x: auto; + table { + min-width: 768px; + } + } +} + + // Dashboard .dashboard { @@ -364,9 +397,6 @@ td.table-empty-message { background-image: url(../img/material-icons/description.svg); } } - .table-wrapper { - overflow-x: scroll; - } } .dashboard-table { @@ -390,6 +420,30 @@ td.table-empty-message { } } +.job-status-table { + table-layout: fixed; + + thead tr th { + border-bottom: 0; + } + + thead, + tbody, + tr { + width: 100%; + } + + th:first-child, + td:first-child { + width: 75%; + } + + th:nth-child(2),R + td:nth-child(2) { + width: 25%; + } +} + .usage-table { ul { list-style: none; @@ -409,27 +463,51 @@ td.table-empty-message { width: 25%; overflow-wrap: anywhere; } + td.jobid { + width: 5%; + } td.template { - width: 20%; + width: 25%; } td.time-sent { - width: 20%; + width: 15%; } td.sender { - width: 15%; + width: 20%; + overflow-wrap: break-word; } td.count-of-recipients { width: 5%; } td.report { - width: 5%; + width: 2%; + text-align: center; + } + td.delivered { + width: 2%; + text-align: center; + } + td.failed { + width: 2%; + text-align: center; + } + td.report img { + padding-top: 5px; } th { padding: 0.5rem 1rem } - td { - padding: 0.5rem 1rem - } +} + +@media (max-width: 768px) { + .usa-table-container--scrollable-mobile { + margin: 0; + overflow-y:hidden; + } +} + +.usa-table th[data-sortable][aria-sort=ascending], .usa-table th[data-sortable][aria-sort=descending] { + background-color: #a1d3ff; } #template-list { @@ -444,6 +522,10 @@ td.table-empty-message { } } +.usa-prose > p.max-width-full { + max-width: 100%; +} + // Tabs .tabs { diff --git a/app/assets/sass/uswds/_uswds-theme.scss b/app/assets/sass/uswds/_uswds-theme.scss index bc4b9b7336..4aaa659a52 100644 --- a/app/assets/sass/uswds/_uswds-theme.scss +++ b/app/assets/sass/uswds/_uswds-theme.scss @@ -13,5 +13,6 @@ in the form $setting: value, $theme-banner-max-width: "desktop-lg", $theme-grid-container-max-width: "desktop-lg", $theme-footer-max-width: "desktop-lg", - $theme-header-max-width: "desktop-lg" + $theme-header-max-width: "desktop-lg", + $theme-identifier-max-width: "desktop-lg" ); diff --git a/app/assets/sass/uswds/styles.scss b/app/assets/sass/uswds/styles.scss index 3ef97c0655..304e2f3465 100644 --- a/app/assets/sass/uswds/styles.scss +++ b/app/assets/sass/uswds/styles.scss @@ -1,4 +1,5 @@ @forward "uswds-theme"; @forward "uswds"; @forward "uswds-theme-custom-styles"; -@forward "legacy-styles"; \ No newline at end of file +@forward "legacy-styles"; +@forward "data-visualization"; diff --git a/app/config.py b/app/config.py index 0a05908f51..960d6331b0 100644 --- a/app/config.py +++ b/app/config.py @@ -2,9 +2,9 @@ from os import getenv import newrelic.agent -from notifications_utils import DAILY_MESSAGE_LIMIT from app.cloudfoundry_config import cloud_config +from notifications_utils import DAILY_MESSAGE_LIMIT class Config(object): @@ -38,7 +38,7 @@ class Config(object): NR_MONITOR_ON = settings and settings.monitor_mode COMMIT_HASH = getenv("COMMIT_HASH", "--------")[0:7] - GOVERNMENT_EMAIL_DOMAIN_NAMES = ["gov"] + GOVERNMENT_EMAIL_DOMAIN_NAMES = ["gov", "mil", "si.edu"] # Logging NOTIFY_LOG_LEVEL = getenv("NOTIFY_LOG_LEVEL", "INFO") @@ -53,7 +53,13 @@ class Config(object): PERMANENT_SESSION_LIFETIME = 1800 # 30 Minutes SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year REPLY_TO_EMAIL_ADDRESS_VALIDATION_TIMEOUT = 45 - ACTIVITY_STATS_LIMIT_DAYS = 7 + ACTIVITY_STATS_LIMIT_DAYS = { + "today": 0, + "one_day": 1, + "three_day": 3, + "five_day": 5, + "seven_day": 7, + } SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_NAME = "notify_admin_session" SESSION_COOKIE_SECURE = True diff --git a/app/extensions.py b/app/extensions.py index 8bbb874a3c..e322e46d06 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,5 +1,3 @@ from notifications_utils.clients.redis.redis_client import RedisClient -from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient -zendesk_client = ZendeskClient() redis_client = RedisClient() diff --git a/app/formatters.py b/app/formatters.py index 397cbbc108..c427c2a9a9 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -13,17 +13,18 @@ import markdown import pytz from bs4 import BeautifulSoup -from flask import Markup, render_template_string, url_for +from flask import render_template_string, url_for from flask.helpers import get_root_path +from markupsafe import Markup + +from app.utils.csv import get_user_preferred_timezone +from app.utils.time import parse_naive_dt from notifications_utils.field import Field from notifications_utils.formatters import make_quotes_smart from notifications_utils.formatters import nl2br as utils_nl2br from notifications_utils.recipients import InvalidPhoneError, validate_phone_number from notifications_utils.take import Take -from app.utils.csv import get_user_preferred_timezone -from app.utils.time import parse_naive_dt - def apply_html_class(tags, html_file): new_html = html_file @@ -230,6 +231,11 @@ def naturaltime_without_indefinite_article(date): ) +def convert_time_unixtimestamp(date_string): + dt = datetime.fromisoformat(date_string) + return int(dt.timestamp()) + + def format_delta(date): # This method assumes that date is in UTC date = parse_naive_dt(date) diff --git a/app/main/__init__.py b/app/main/__init__.py index 8626582f2d..2375c8e580 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -3,6 +3,7 @@ main = Blueprint("main", __name__) from app.main.views import ( # noqa isort:skip + activity, add_service, api_keys, choose_account, diff --git a/app/main/forms.py b/app/main/forms.py index 13a463a515..90a4409fcc 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -4,15 +4,13 @@ from numbers import Number import pytz -from flask import Markup, render_template, request +from flask import render_template, request from flask_login import current_user from flask_wtf import FlaskForm as Form from flask_wtf.file import FileAllowed from flask_wtf.file import FileField as FileField_wtf from flask_wtf.file import FileSize -from notifications_utils.formatters import strip_all_whitespace -from notifications_utils.insensitive_dict import InsensitiveDict -from notifications_utils.recipients import InvalidPhoneError, validate_phone_number +from markupsafe import Markup from werkzeug.utils import cached_property from wtforms import ( BooleanField, @@ -52,6 +50,7 @@ CommonlyUsedPassword, CsvFileValidator, DoesNotStartWithDoubleZero, + FieldCannotContainComma, LettersNumbersSingleQuotesFullStopsAndUnderscoresOnly, MustContainAlphanumericCharacters, NoCommasInPlaceHolders, @@ -65,6 +64,9 @@ from app.utils import merge_jsonlike from app.utils.csv import get_user_preferred_timezone from app.utils.user_permissions import all_ui_permissions, permission_options +from notifications_utils.formatters import strip_all_whitespace +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.recipients import InvalidPhoneError, validate_phone_number def get_time_value_and_label(future_time): @@ -1649,7 +1651,11 @@ def get_placeholder_form_instance( ) # TODO: replace with us_mobile_number else: field = GovukTextInputField( - placeholder_name, validators=[DataRequired(message="Cannot be empty")] + placeholder_name, + validators=[ + DataRequired(message="Cannot be empty"), + FieldCannotContainComma(), + ], ) PlaceholderForm.placeholder_value = field diff --git a/app/main/validators.py b/app/main/validators.py index 8ea7934bf2..1dfc97c488 100644 --- a/app/main/validators.py +++ b/app/main/validators.py @@ -1,15 +1,15 @@ import re from abc import ABC, abstractmethod -from notifications_utils.field import Field -from notifications_utils.formatters import formatted_list -from notifications_utils.recipients import InvalidEmailError, validate_email_address -from notifications_utils.sanitise_text import SanitiseSMS from wtforms import ValidationError from app.main._commonly_used_passwords import commonly_used_passwords from app.models.spreadsheet import Spreadsheet from app.utils.user import is_gov_user +from notifications_utils.field import Field +from notifications_utils.formatters import formatted_list +from notifications_utils.recipients import InvalidEmailError, validate_email_address +from notifications_utils.sanitise_text import SanitiseSMS class CommonlyUsedPassword: @@ -161,6 +161,15 @@ def __call__(self, form, field): raise ValidationError(self.message) +class FieldCannotContainComma: + def __init__(self, message="Cannot contain a comma"): + self.message = message + + def __call__(self, form, field): + if field.data and "," in field.data: + raise ValidationError(self.message) + + class MustContainAlphanumericCharacters: regex = re.compile(r".*[a-zA-Z0-9].*[a-zA-Z0-9].*") diff --git a/app/main/views/activity.py b/app/main/views/activity.py new file mode 100644 index 0000000000..b1cafdb6a3 --- /dev/null +++ b/app/main/views/activity.py @@ -0,0 +1,124 @@ +from flask import abort, render_template, request, url_for + +from app import current_service, job_api_client +from app.formatters import convert_time_unixtimestamp, get_time_left +from app.main import main +from app.utils.pagination import ( + generate_next_dict, + generate_pagination_pages, + generate_previous_dict, + get_page_from_request, +) +from app.utils.user import user_has_permissions + + +@main.route("/activity/services/") +@user_has_permissions("view_activity") +def all_jobs_activity(service_id): + service_data_retention_days = 7 + page = get_page_from_request() + jobs = job_api_client.get_page_of_jobs(service_id, page=page) + all_jobs_dict = generate_job_dict(jobs) + prev_page, next_page, pagination = handle_pagination(jobs, service_id, page) + message_type = ("sms",) + return render_template( + "views/activity/all-activity.html", + all_jobs_dict=all_jobs_dict, + service_data_retention_days=service_data_retention_days, + next_page=next_page, + prev_page=prev_page, + pagination=pagination, + download_link_one_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="one_day", + ), + download_link_three_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="three_day", + ), + download_link_five_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="five_day", + ), + download_link_seven_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="seven_day", + ), + ) + + +def handle_pagination(jobs, service_id, page): + if page is None: + abort(404, "Invalid page argument ({}).".format(request.args.get("page"))) + prev_page = ( + generate_previous_dict("main.all_jobs_activity", service_id, page) + if page > 1 + else None + ) + next_page = ( + generate_next_dict("main.all_jobs_activity", service_id, page) + if jobs.get("links", {}).get("next") + else None + ) + pagination = generate_pagination_pages( + jobs.get("total", {}), jobs.get("page_size", {}), page + ) + return prev_page, next_page, pagination + + +def generate_job_dict(jobs): + return [ + { + "job_id": job["id"], + "time_left": get_time_left(job["created_at"]), + "download_link": url_for( + ".view_job_csv", service_id=current_service.id, job_id=job["id"] + ), + "view_job_link": url_for( + ".view_job", service_id=current_service.id, job_id=job["id"] + ), + "created_at": job["created_at"], + "time_sent_data_value": convert_time_unixtimestamp( + job["processing_finished"] + if job["processing_finished"] + else ( + job["processing_started"] + if job["processing_started"] + else job["created_at"] + ) + ), + "processing_finished": job["processing_finished"], + "processing_started": job["processing_started"], + "created_by": job["created_by"], + "template_name": job["template_name"], + "delivered_count": next( + ( + stat["count"] + for stat in job.get("statistics", []) + if stat["status"] == "delivered" + ), + None, + ), + "failed_count": next( + ( + stat["count"] + for stat in job.get("statistics", []) + if stat["status"] == "failed" + ), + None, + ), + } + for job in jobs["data"] + ] diff --git a/app/main/views/api_keys.py b/app/main/views/api_keys.py index 8cb28ba591..f93d2caa16 100644 --- a/app/main/views/api_keys.py +++ b/app/main/views/api_keys.py @@ -1,5 +1,6 @@ -from flask import Markup, abort, flash, redirect, render_template, request, url_for +from flask import abort, flash, redirect, render_template, request, url_for from flask_login import current_user +from markupsafe import Markup from app import ( api_key_api_client, diff --git a/app/main/views/conversation.py b/app/main/views/conversation.py index 4b14ddf31e..a3ac47da75 100644 --- a/app/main/views/conversation.py +++ b/app/main/views/conversation.py @@ -1,14 +1,14 @@ from flask import jsonify, redirect, render_template, session, url_for from flask_login import current_user from notifications_python_client.errors import HTTPError -from notifications_utils.recipients import format_phone_number_human_readable -from notifications_utils.template import SMSPreviewTemplate from app import current_service, notification_api_client, service_api_client from app.main import main from app.main.forms import SearchByNameForm from app.models.template_list import TemplateList from app.utils.user import user_has_permissions +from notifications_utils.recipients import format_phone_number_human_readable +from notifications_utils.template import SMSPreviewTemplate @main.route("/services//conversation/") diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 8453ef369e..0913dcfdda 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -1,19 +1,16 @@ import calendar -from collections import defaultdict from datetime import datetime from functools import partial from itertools import groupby from flask import Response, abort, jsonify, render_template, request, session, url_for from flask_login import current_user -from notifications_utils.recipients import format_phone_number_human_readable from werkzeug.utils import redirect from app import ( billing_api_client, current_service, job_api_client, - notification_api_client, service_api_client, template_statistics_client, ) @@ -30,6 +27,7 @@ from app.utils.pagination import generate_next_dict, generate_previous_dict from app.utils.time import get_current_financial_year from app.utils.user import user_has_permissions +from notifications_utils.recipients import format_phone_number_human_readable @main.route("/services//dashboard") @@ -48,19 +46,21 @@ def service_dashboard(service_id): if not current_user.has_permissions("view_activity"): return redirect(url_for("main.choose_template", service_id=service_id)) + yearly_usage = billing_api_client.get_annual_usage_for_service( + service_id, + get_current_financial_year(), + ) + free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year( + current_service.id, + ) + usage_data = get_annual_usage_breakdown(yearly_usage, free_sms_allowance) + sms_sent = usage_data["sms_sent"] + sms_allowance_remaining = usage_data["sms_allowance_remaining"] + job_response = job_api_client.get_jobs(service_id)["data"] - notifications_response = notification_api_client.get_notifications_for_service( - service_id - )["notifications"] service_data_retention_days = 7 - aggregate_notifications_by_job = defaultdict(list) - for notification in notifications_response: - job_id = notification.get("job", {}).get("id", None) - if job_id: - aggregate_notifications_by_job[job_id].append(notification) - - job_and_notifications = [ + jobs = [ { "job_id": job["id"], "time_left": get_time_left(job["created_at"]), @@ -71,20 +71,50 @@ def service_dashboard(service_id): ".view_job", service_id=current_service.id, job_id=job["id"] ), "created_at": job["created_at"], + "processing_finished": job.get("processing_finished"), + "processing_started": job.get("processing_started"), "notification_count": job["notification_count"], "created_by": job["created_by"], - "notifications": aggregate_notifications_by_job.get(job["id"], []), + "template_name": job["template_name"], + "original_file_name": job["original_file_name"], } for job in job_response - if aggregate_notifications_by_job.get(job["id"], []) + if job["job_status"] != "cancelled" ] return render_template( "views/dashboard/dashboard.html", updates_url=url_for(".service_dashboard_updates", service_id=service_id), partials=get_dashboard_partials(service_id), - job_and_notifications=job_and_notifications, + jobs=jobs, service_data_retention_days=service_data_retention_days, + sms_sent=sms_sent, + sms_allowance_remaining=sms_allowance_remaining, + ) + + +@main.route("/daily_stats.json") +def get_daily_stats(): + service_id = session.get("service_id") + date_range = get_stats_date_range() + + stats = service_api_client.get_service_notification_statistics_by_day( + service_id, start_date=date_range["start_date"], days=date_range["days"] + ) + return jsonify(stats) + + +@main.route("/daily_stats_by_user.json") +def get_daily_stats_by_user(): + service_id = session.get("service_id") + date_range = get_stats_date_range() + user_id = current_user.id + stats = service_api_client.get_user_service_notification_statistics_by_day( + service_id, + user_id, + start_date=date_range["start_date"], + days=date_range["days"], ) + return jsonify(stats) @main.route("/services//dashboard.json") @@ -434,6 +464,24 @@ def get_months_for_financial_year(year, time_format="%B"): return [month.strftime(time_format) for month in (get_months_for_year(1, 13, year))] +def get_current_month_for_financial_year(year): + current_month = datetime.now().month + return current_month + + +def get_stats_date_range(): + current_financial_year = get_current_financial_year() + current_month = get_current_month_for_financial_year(current_financial_year) + start_date = datetime.now().strftime("%Y-%m-%d") + days = 7 + return { + "current_financial_year": current_financial_year, + "current_month": current_month, + "start_date": start_date, + "days": days, + } + + def get_months_for_year(start, end, year): return [datetime(year, month, 1) for month in range(start, end)] diff --git a/app/main/views/index.py b/app/main/views/index.py index c68605b2ef..ec489d5ac9 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -1,6 +1,6 @@ import os -from flask import abort, redirect, render_template, request, url_for +from flask import abort, current_app, redirect, render_template, request, url_for from flask_login import current_user from app import status_api_client @@ -9,20 +9,28 @@ from app.main.views.pricing import CURRENT_SMS_RATE from app.main.views.sub_navigation_dictionaries import features_nav, using_notify_nav from app.utils.user import user_is_logged_in - -login_dot_gov_url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL") +from notifications_utils.url_safe_token import generate_token @main.route("/") def index(): if current_user and current_user.is_authenticated: return redirect(url_for("main.choose_account")) - + token = generate_token( + str(request.remote_addr), + current_app.config["SECRET_KEY"], + current_app.config["DANGEROUS_SALT"], + ) + url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL") + # handle unit tests + if url is not None: + url = url.replace("NONCE", token) + url = url.replace("STATE", token) return render_template( "views/signedout.html", sms_rate=CURRENT_SMS_RATE, counts=status_api_client.get_count_of_live_services_and_organizations(), - login_dot_gov_url=login_dot_gov_url, + initial_signin_url=url, ) diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index 0c0848e469..dddf838a1c 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -3,7 +3,6 @@ from functools import partial from flask import ( - Markup, Response, abort, jsonify, @@ -15,7 +14,7 @@ url_for, ) from flask_login import current_user -from notifications_utils.template import EmailPreviewTemplate, SMSBodyPreviewTemplate +from markupsafe import Markup from app import ( current_service, @@ -35,6 +34,7 @@ get_page_from_request, ) from app.utils.user import user_has_permissions +from notifications_utils.template import EmailPreviewTemplate, SMSBodyPreviewTemplate @main.route("/services//jobs") @@ -143,11 +143,40 @@ def view_notifications(service_id, message_type=None): True: ["reference"], False: [], }.get(bool(current_service.api_keys)), - download_link=url_for( + download_link_one_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="one_day", + ), + download_link_today=url_for( ".download_notifications_csv", service_id=current_service.id, message_type=message_type, status=request.args.get("status"), + number_of_days="today", + ), + download_link_three_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="three_day", + ), + download_link_five_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="five_day", + ), + download_link_seven_day=url_for( + ".download_notifications_csv", + service_id=current_service.id, + message_type=message_type, + status=request.args.get("status"), + number_of_days="seven_day", ), ) @@ -183,10 +212,9 @@ def get_notifications(service_id, message_type, status_override=None): # noqa filter_args["status"] = set_status_filters(filter_args) service_data_retention_days = None search_term = request.form.get("to", "") - if message_type is not None: service_data_retention_days = current_service.get_days_of_retention( - message_type + message_type, number_of_days="seven_day" ) if request.path.endswith("csv") and current_user.has_permissions("view_activity"): @@ -212,7 +240,6 @@ def get_notifications(service_id, message_type, status_override=None): # noqa ) url_args = {"message_type": message_type, "status": request.args.get("status")} prev_page = None - if "links" in notifications and notifications["links"].get("prev", None): prev_page = generate_previous_dict( "main.view_notifications", service_id, page, url_args=url_args @@ -233,7 +260,6 @@ def get_notifications(service_id, message_type, status_override=None): # noqa ) else: download_link = None - return { "service_data_retention_days": service_data_retention_days, "counts": render_template( @@ -286,7 +312,7 @@ def get_status_filters(service, message_type, statistics): filters = [ # key, label, option ("requested", "total", "sending,delivered,failed"), - ("pending", "pending", "pending"), + ("pending", "pending", "sending,pending"), ("delivered", "delivered", "delivered"), ("failed", "failed", "failed"), ] @@ -362,6 +388,7 @@ def get_job_partials(job): filter_args = parse_filter_args(request.args) filter_args["status"] = set_status_filters(filter_args) notifications = job.get_notifications(status=filter_args["status"]) + number_of_days = "seven_day" counts = render_template( "partials/count.html", counts=_get_job_counts(job), @@ -371,7 +398,7 @@ def get_job_partials(job): ), ) service_data_retention_days = current_service.get_days_of_retention( - job.template_type + job.template_type, number_of_days ) if request.referrer is not None: diff --git a/app/main/views/new_password.py b/app/main/views/new_password.py index 4bf3747178..222268c36d 100644 --- a/app/main/views/new_password.py +++ b/app/main/views/new_password.py @@ -10,12 +10,12 @@ url_for, ) from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import check_token from app.main import main from app.main.forms import NewPasswordForm from app.models.user import User from app.utils.login import log_in_user +from notifications_utils.url_safe_token import check_token @main.route("/new-password/", methods=["GET", "POST"]) diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py index ac05e05ffe..e41708b8c2 100644 --- a/app/main/views/notifications.py +++ b/app/main/views/notifications.py @@ -137,9 +137,9 @@ def get_all_personalisation_from_notification(notification): def download_notifications_csv(service_id): filter_args = parse_filter_args(request.args) filter_args["status"] = set_status_filters(filter_args) - + number_of_days = request.args["number_of_days"] service_data_retention_days = current_service.get_days_of_retention( - filter_args.get("message_type")[0] + filter_args.get("message_type")[0], number_of_days ) file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") file_time = f"{file_time} {get_user_preferred_timezone()}" diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py index 6190ac988a..f0b5983b51 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -1,9 +1,11 @@ +import csv import itertools import json from collections import OrderedDict from datetime import datetime +from io import StringIO -from flask import abort, flash, render_template, request, url_for +from flask import Response, abort, flash, render_template, request, url_for from notifications_python_client.errors import HTTPError from app import ( @@ -70,6 +72,40 @@ def platform_admin(): ) +@main.route("/platform-admin/download-all-users") +@user_is_platform_admin +def download_all_users(): + + # Create a CSV string from the user data + users = user_api_client.get_all_users_detailed() + + if len(users) == 0: + return "No data to download." + + output = StringIO() + header = ["Name", "Email Address", "Phone Number", "Service"] + fieldnames = ["name", "email_address", "mobile_number", "service"] + writer = csv.DictWriter( + output, + fieldnames=fieldnames, + delimiter=",", + ) + # Write custom header + writer.writerow(dict(zip(fieldnames, header))) + for user in users: + user_no_commas = {key: value.replace(",", "") for key, value in user.items()} + if user_no_commas["name"].startswith("e2e"): + continue + writer.writerow(user_no_commas) + csv_data = output.getvalue() + + # Create a direct download response with the CSV data and appropriate headers + response = Response(csv_data, content_type="text/csv; charset=utf-8") + response.headers["Content-Disposition"] = "attachment; filename=users.csv" + + return response + + def is_over_threshold(number, total, threshold): percentage = number / total * 100 if total else 0 return percentage > threshold diff --git a/app/main/views/pricing.py b/app/main/views/pricing.py index 0e5fb361db..5e60e6768a 100644 --- a/app/main/views/pricing.py +++ b/app/main/views/pricing.py @@ -1,11 +1,11 @@ from flask import current_app, render_template from flask_login import current_user -from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES from app.main import main from app.main.forms import SearchByNameForm from app.main.views.sub_navigation_dictionaries import using_notify_nav from app.utils.user import user_is_logged_in +from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES CURRENT_SMS_RATE = "1.72" diff --git a/app/main/views/register.py b/app/main/views/register.py index e6350c8f7e..19187a47c7 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -1,9 +1,12 @@ +import base64 +import json import uuid from datetime import datetime, timedelta from flask import ( abort, current_app, + flash, redirect, render_template, request, @@ -12,18 +15,18 @@ ) from flask_login import current_user -from app import user_api_client +from app import redis_client, user_api_client from app.main import main from app.main.forms import ( RegisterUserForm, - RegisterUserFromInviteForm, RegisterUserFromOrgInviteForm, SetupUserProfileForm, ) from app.main.views import sign_in from app.main.views.verify import activate_user from app.models.user import InvitedOrgUser, InvitedUser, User -from app.utils import hide_from_search_engines +from app.utils import hide_from_search_engines, hilite +from app.utils.user import is_gov_user @main.route("/register", methods=["GET", "POST"]) @@ -40,35 +43,10 @@ def register(): return render_template("views/register.html", form=form) -@main.route("/register-from-invite", methods=["GET", "POST"]) -def register_from_invite(): - invited_user = InvitedUser.from_session() - if not invited_user: - abort(404) - - form = RegisterUserFromInviteForm(invited_user) - - if form.validate_on_submit(): - if ( - form.service.data != invited_user.service - or form.email_address.data != invited_user.email_address - ): - abort(400) - _do_registration(form, send_email=False, send_sms=invited_user.sms_auth) - invited_user.accept_invite() - if invited_user.sms_auth: - return redirect(url_for("main.verify")) - else: - # we've already proven this user has email because they clicked the invite link, - # so just activate them straight away - return activate_user(session["user_details"]["id"]) - - return render_template( - "views/register-from-invite.html", invited_user=invited_user, form=form - ) - - @main.route("/register-from-org-invite", methods=["GET", "POST"]) +# TODO This is deprecated, we are now handling invites in the +# login.gov workflow. Leaving it here until we write the new +# org registration. def register_from_org_invite(): invited_org_user = InvitedOrgUser.from_session() if not invited_org_user: @@ -136,31 +114,94 @@ def registration_continue(): raise Exception("Unexpected routing in registration_continue") +def get_invite_data_from_redis(state): + + invite_data = json.loads(redis_client.get(f"invitedata-{state}")) + user_email = redis_client.get(f"user_email-{state}").decode("utf8") + user_uuid = redis_client.get(f"user_uuid-{state}").decode("utf8") + invited_user_email_address = redis_client.get( + f"invited_user_email_address-{state}" + ).decode("utf8") + return invite_data, user_email, user_uuid, invited_user_email_address + + +def put_invite_data_in_redis( + state, invite_data, user_email, user_uuid, invited_user_email_address +): + ttl = 60 * 15 # 15 minutes + + redis_client.set(f"invitedata-{state}", json.dumps(invite_data), ex=ttl) + redis_client.set(f"user_email-{state}", user_email, ex=ttl) + redis_client.set(f"user_uuid-{state}", user_uuid, ex=ttl) + redis_client.set( + f"invited_user_email_address-{state}", + invited_user_email_address, + ex=ttl, + ) + + +def check_invited_user_email_address_matches_expected( + user_email, invited_user_email_address +): + if user_email.lower() != invited_user_email_address.lower(): + debug_msg("invited user email did not match expected email, abort(403)") + flash("You cannot accept an invite for another person.") + abort(403) + + if not is_gov_user(user_email): + debug_msg("invited user has a non-government email address.") + flash("You must use a government email address.") + abort(403) + + @main.route("/set-up-your-profile", methods=["GET", "POST"]) @hide_from_search_engines def set_up_your_profile(): + debug_msg(f"Enter set_up_your_profile with request.args {request.args}") + code = request.args.get("code") + state = request.args.get("state") + login_gov_error = request.args.get("error") + + if redis_client.get(f"invitedata-{state}") is None: + access_token = sign_in._get_access_token(code, state) + debug_msg("Got the access token for login.gov") + user_email, user_uuid = sign_in._get_user_email_and_uuid(access_token) + debug_msg( + f"Got the user_email {user_email} and user_uuid {user_uuid} from login.gov" + ) + invite_data = state.encode("utf8") + invite_data = base64.b64decode(invite_data) + invite_data = json.loads(invite_data) + debug_msg(f"final state {invite_data}") + invited_user_id = invite_data["invited_user_id"] + invited_user_email_address = get_invited_user_email_address(invited_user_id) + debug_msg(f"email address from the invite_date is {invited_user_email_address}") + check_invited_user_email_address_matches_expected( + user_email, invited_user_email_address + ) + + invited_user_accept_invite(invited_user_id) + debug_msg( + f"accepted invite user {invited_user_email_address} to service {invite_data['service_id']}" + ) + # We need to avoid taking a second trip through the login.gov code because we cannot pull the + # access token twice. So once we retrieve these values, let's park them in redis for 15 minutes + put_invite_data_in_redis( + state, invite_data, user_email, user_uuid, invited_user_email_address + ) + form = SetupUserProfileForm() - if form.validate_on_submit(): - # start login.gov - code = request.args.get("code") - state = request.args.get("state") - login_gov_error = request.args.get("error") - if code and state: - access_token = sign_in._get_access_token(code, state) - user_email, user_uuid = sign_in._get_user_email_and_uuid(access_token) - redirect_url = request.args.get("next") - - elif login_gov_error: - current_app.logger.error(f"login.gov error: {login_gov_error}") - raise Exception(f"Could not login with login.gov {login_gov_error}") - # end login.gov - - # create the user - # TODO we have to provide something for password until that column goes away - # TODO ideally we would set the user's preferred timezone here as well + if ( + form.validate_on_submit() + and redis_client.get(f"invitedata-{state}") is not None + ): + invite_data, user_email, user_uuid, invited_user_email_address = ( + get_invite_data_from_redis(state) + ) + # create or update the user user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email) if user is None: user = User.register( @@ -170,9 +211,68 @@ def set_up_your_profile(): password=str(uuid.uuid4()), auth_type="sms_auth", ) + debug_msg(f"registered user {form.name.data} with email {user_email}") + else: + user.update(mobile_number=form.mobile_number.data, name=form.name.data) + debug_msg(f"updated user {form.name.data}") # activate the user user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email) activate_user(user["id"]) - return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) + debug_msg("activated user") + usr = User.from_id(user["id"]) + usr.add_to_service( + invite_data["service_id"], + invite_data["permissions"], + invite_data["folder_permissions"], + invite_data["from_user_id"], + ) + debug_msg( + f"Added user {usr.email_address} to service {invite_data['service_id']}" + ) + # notify-admin-1766 + # redirect new users to templates area of new service instead of dashboard + service_id = invite_data["service_id"] + url = url_for(".service_dashboard", service_id=service_id) + url = f"{url}/templates" + return redirect(url) + + elif login_gov_error: + current_app.logger.error(f"login.gov error: {login_gov_error}") + abort(403) + + # we take two trips through this method, but should only hit this + # line on the first trip. On the second trip, we should get redirected + # to the accounts page because we have successfully registered. return render_template("views/set-up-your-profile.html", form=form) + + +def get_invited_user_email_address(invited_user_id): + # InvitedUser is an unhashable type and hard to mock in tests + # so this convenience method is a workaround for that + invited_user = InvitedUser.by_id(invited_user_id) + return invited_user.email_address + + +def invited_user_accept_invite(invited_user_id): + invited_user = InvitedUser.by_id(invited_user_id) + + if invited_user.status == "expired": + current_app.logger.error("User invitation has expired") + flash( + "Your invitation has expired; please contact the person who invited you for additional help." + ) + abort(401) + + if invited_user.status == "cancelled": + current_app.logger.error("User invitation has been cancelled") + flash( + "Your invitation is no longer valid; please contact the person who invited you for additional help." + ) + abort(401) + + invited_user.accept_invite() + + +def debug_msg(msg): + current_app.logger.debug(hilite(msg)) diff --git a/app/main/views/send.py b/app/main/views/send.py index 02f7d21211..6f2817826f 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -3,14 +3,19 @@ from string import ascii_uppercase from zipfile import BadZipFile -from flask import abort, flash, redirect, render_template, request, session, url_for +from flask import ( + abort, + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) from flask_login import current_user from markupsafe import Markup from notifications_python_client.errors import HTTPError -from notifications_utils import SMS_CHAR_COUNT_LIMIT -from notifications_utils.insensitive_dict import InsensitiveDict -from notifications_utils.recipients import RecipientCSV, first_column_headings -from notifications_utils.sanitise_text import SanitiseASCII from xlrd.biffh import XLRDError from xlrd.xldate import XLDateError @@ -35,10 +40,19 @@ s3upload, set_metadata_on_csv_upload, ) -from app.utils import PermanentRedirect, should_skip_template_page, unicode_truncate +from app.utils import ( + PermanentRedirect, + hilite, + should_skip_template_page, + unicode_truncate, +) from app.utils.csv import Spreadsheet, get_errors_for_csv from app.utils.templates import get_template from app.utils.user import user_has_permissions +from notifications_utils import SMS_CHAR_COUNT_LIMIT +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.recipients import RecipientCSV, first_column_headings +from notifications_utils.sanitise_text import SanitiseASCII def get_example_csv_fields(column_headers, use_example_as_example, submitted_fields): @@ -948,9 +962,22 @@ def send_notification(service_id, template_id): vals = ",".join(values) data = f"{data}\r\n{vals}" - filename = f"one-off-{current_user.name}-{uuid.uuid4()}.csv" + filename = ( + f"one-off-{uuid.uuid4()}.csv" # {current_user.name} removed from filename + ) my_data = {"filename": filename, "template_id": template_id, "data": data} upload_id = s3upload(service_id, my_data) + + # To debug messages that the user reports have not been sent, we log + # the csv filename and the job id. The user will give us the file name, + # so we can search on that to obtain the job id, which we can use elsewhere + # on the API side to find out what happens to the message. + current_app.logger.info( + hilite( + f"One-off file: {filename} job_id: {upload_id} s3 location: service-{service_id}-notify/{upload_id}.csv" + ) + ) + form = CsvUploadForm() form.file.data = my_data form.file.name = filename @@ -1000,14 +1027,17 @@ def send_notification(service_id, template_id): job_id=upload_id, ) ) - + total = notifications["total"] + current_app.logger.info( + hilite( + f"job_id: {upload_id} has notifications: {total} and attempts: {attempts}" + ) + ) return redirect( url_for( ".view_job", service_id=service_id, job_id=upload_id, - from_job=upload_id, - notification_id=notifications["notifications"][0]["id"], # used to show the final step of the tour (help=3) or not show # a back link on a just sent one off notification (help=0) help=request.args.get("help"), @@ -1023,8 +1053,13 @@ def get_email_reply_to_address_from_session(): def get_sms_sender_from_session(): - if session.get("sender_id"): - return current_service.get_sms_sender(session["sender_id"])["sms_sender"] + sender_id = session.get("sender_id") + if sender_id: + sms_sender = current_service.get_sms_sender(session["sender_id"])["sms_sender"] + current_app.logger.info(f"SMS Sender ({sender_id}) #: {sms_sender}") + return sms_sender + else: + current_app.logger.error("No SMS Sender!!!!!!") def get_spreadsheet_column_headings_from_template(template): diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index ee427322e5..e5f19e7cb8 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -5,7 +5,6 @@ import jwt import requests from flask import ( - Markup, Response, abort, current_app, @@ -13,32 +12,28 @@ redirect, render_template, request, - session, url_for, ) from flask_login import current_user -from notifications_utils.url_safe_token import generate_token from app import login_manager, user_api_client from app.main import main -from app.main.forms import LoginForm from app.main.views.index import error from app.main.views.verify import activate_user -from app.models.user import InvitedUser, User +from app.models.user import User from app.utils import hide_from_search_engines from app.utils.login import is_safe_redirect_url from app.utils.time import is_less_than_days_ago +from app.utils.user import is_gov_user +from notifications_utils.url_safe_token import generate_token def _reformat_keystring(orig): - new_keystring = orig.replace("-----BEGIN PRIVATE KEY-----", "") - new_keystring = new_keystring.replace("-----END PRIVATE KEY-----", "") - new_keystring = new_keystring.strip() - new_keystring = new_keystring.replace(" ", "\n") - new_keystring = "\n".join( - ["-----BEGIN PRIVATE KEY-----", new_keystring, "-----END PRIVATE KEY-----"] - ) - new_keystring = f"{new_keystring}\n" + arr = orig.split("-----") + begin = arr[1] + end = arr[3] + middle = arr[2].strip() + new_keystring = f"-----{begin}-----\n{middle}\n-----{end}-----\n" return new_keystring @@ -67,7 +62,9 @@ def _get_access_token(code, state): response = requests.post(url, headers=headers) if response.json().get("access_token") is None: # Capture the response json here so it hopefully shows up in error reports - current_app.logger.error(f"Error when getting access token {response.json()}") + current_app.logger.error( + f"Error when getting access token {response.json()} #notify-admin-1505" + ) raise KeyError(f"'access_token' {response.json()}") access_token = response.json()["access_token"] return access_token @@ -92,7 +89,9 @@ def _do_login_dot_gov(): login_gov_error = request.args.get("error") if login_gov_error: - current_app.logger.error(f"login.gov error: {login_gov_error}") + current_app.logger.error( + f"login.gov error: {login_gov_error} #notify-admin-1505" + ) raise Exception(f"Could not login with login.gov {login_gov_error}") elif code and state: @@ -100,8 +99,17 @@ def _do_login_dot_gov(): try: access_token = _get_access_token(code, state) user_email, user_uuid = _get_user_email_and_uuid(access_token) + if not is_gov_user(user_email): + current_app.logger.error( + "invited user has a non-government email address. #notify-admin-1505" + ) + flash("You must use a government email address.") + abort(403) redirect_url = request.args.get("next") user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email) + current_app.logger.info( + f"Retrieved user {user['id']} from db #notify-admin-1505" + ) # Check if the email needs to be revalidated is_fresh_email = is_less_than_days_ago( @@ -111,9 +119,10 @@ def _do_login_dot_gov(): return verify_email(user, redirect_url) usr = User.from_email_address(user["email_address"]) + current_app.logger.info(f"activating user {usr.id} #notify-admin-1505") activate_user(usr.id) except BaseException as be: # noqa B036 - current_app.logger.error(be) + current_app.logger.error(f"Error signing in: {be} #notify-admin-1505 ") error(401) return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) @@ -129,6 +138,16 @@ def verify_email(user, redirect_url): ) +def _handle_e2e_tests(redirect_url): + current_app.logger.warning("E2E TESTS ARE ENABLED.") + current_app.logger.warning( + "If you are getting a 404 on signin, comment out E2E vars in .env file!" + ) + user = user_api_client.get_user_by_email(os.getenv("NOTIFY_E2E_TEST_EMAIL")) + activate_user(user["id"]) + return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) + + @main.route("/sign-in", methods=(["GET", "POST"])) @hide_from_search_engines def sign_in(): @@ -146,70 +165,13 @@ def sign_in(): redirect_url = request.args.get("next") if os.getenv("NOTIFY_E2E_TEST_EMAIL"): - current_app.logger.warning("E2E TESTS ARE ENABLED.") - current_app.logger.warning( - "If you are getting a 404 on signin, comment out E2E vars in .env file!" - ) - user = user_api_client.get_user_by_email(os.getenv("NOTIFY_E2E_TEST_EMAIL")) - activate_user(user["id"]) - return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) + return _handle_e2e_tests(redirect_url) - current_app.logger.info(f"current user is {current_user}") if current_user and current_user.is_authenticated: if redirect_url and is_safe_redirect_url(redirect_url): return redirect(redirect_url) return redirect(url_for("main.show_accounts_or_dashboard")) - form = LoginForm() - current_app.logger.info("Got the login form") - password_reset_url = url_for(".forgot_password", next=request.args.get("next")) - - if form.validate_on_submit(): - user = User.from_email_address_and_password_or_none( - form.email_address.data, form.password.data - ) - - if user: - # add user to session to mark us as in the process of signing the user in - session["user_details"] = {"email": user.email_address, "id": user.id} - - if user.state == "pending": - return redirect( - url_for("main.resend_email_verification", next=redirect_url) - ) - - if user.is_active: - if session.get("invited_user_id"): - invited_user = InvitedUser.from_session() - if user.email_address.lower() != invited_user.email_address.lower(): - flash("You cannot accept an invite for another person.") - session.pop("invited_user_id", None) - abort(403) - else: - invited_user.accept_invite() - - user.send_login_code() - - if user.sms_auth: - return redirect(url_for(".two_factor_sms", next=redirect_url)) - - if user.email_auth: - return redirect( - url_for(".two_factor_email_sent", next=redirect_url) - ) - - # Vague error message for login in case of user not known, locked, inactive or password not verified - flash( - Markup( - ( - f"The email address or password you entered is incorrect." - f" Forgot your password?" - ) - ) - ) - - other_device = current_user.logged_in_elsewhere() - token = generate_token( str(request.remote_addr), current_app.config["SECRET_KEY"], @@ -220,13 +182,9 @@ def sign_in(): if url is not None: url = url.replace("NONCE", token) url = url.replace("STATE", token) - return render_template( "views/signin.html", - form=form, again=bool(redirect_url), - other_device=other_device, - password_reset_url=password_reset_url, initial_signin_url=url, ) diff --git a/app/main/views/sign_out.py b/app/main/views/sign_out.py index 5ec96e1898..82ba5497e6 100644 --- a/app/main/views/sign_out.py +++ b/app/main/views/sign_out.py @@ -1,7 +1,7 @@ import os import requests -from flask import current_app, redirect, url_for +from flask import current_app, redirect, session, url_for from flask_login import current_user from app.main import main @@ -25,12 +25,16 @@ def _sign_out_at_login_dot_gov(): @main.route("/sign-out", methods=(["GET", "POST"])) def sign_out(): - # An AnonymousUser does not have an id - current_app.logger.info("HIT THE REGULAR SIGN OUT") + if current_user.is_authenticated: - # TODO This doesn't work yet, due to problems above. + current_user.deactivate() + session.clear() current_user.sign_out() + + session.permanent = False + login_dot_gov_logout_url = os.getenv("LOGIN_DOT_GOV_LOGOUT_URL") if login_dot_gov_logout_url: + current_app.config["SESSION_PERMANENT"] = False return redirect(login_dot_gov_logout_url) return redirect(url_for("main.index")) diff --git a/app/main/views/sub_navigation_dictionaries.py b/app/main/views/sub_navigation_dictionaries.py index 5e32bc003b..5c7cf26bac 100644 --- a/app/main/views/sub_navigation_dictionaries.py +++ b/app/main/views/sub_navigation_dictionaries.py @@ -32,7 +32,7 @@ def using_notify_nav(): "link": "main.trial_mode_new", }, { - "name": "Pricing", + "name": "Tracking usage", "link": "main.pricing", }, { diff --git a/app/main/views/templates.py b/app/main/views/templates.py index e9e5f5b613..5c59e1e7cc 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -4,7 +4,6 @@ from flask_login import current_user from markupsafe import Markup from notifications_python_client.errors import HTTPError -from notifications_utils import SMS_CHAR_COUNT_LIMIT from app import ( current_service, @@ -30,6 +29,7 @@ from app.utils import NOTIFICATION_TYPES, should_skip_template_page from app.utils.templates import get_template from app.utils.user import user_has_permissions +from notifications_utils import SMS_CHAR_COUNT_LIMIT form_objects = { "email": EmailTemplateForm, diff --git a/app/main/views/tour.py b/app/main/views/tour.py index 3a93b7384b..0e4b5f344f 100644 --- a/app/main/views/tour.py +++ b/app/main/views/tour.py @@ -195,10 +195,10 @@ def check_tour_notification(service_id, template_id): ) return render_template( - "views/notifications/check.html", + "views/notifications/preview.html", template=template, back_link=back_link, - help="2", + help="3", ) diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 560df1fadd..093e798459 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -3,7 +3,6 @@ from flask import current_app, redirect, render_template, request, session, url_for from flask_login import current_user from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import check_token from app import user_api_client from app.main import main @@ -15,6 +14,7 @@ redirect_to_sign_in, redirect_when_logged_in, ) +from notifications_utils.url_safe_token import check_token @main.route("/two-factor-email-sent", methods=["GET"]) diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py index 5d10fbf75b..2efac8b236 100644 --- a/app/main/views/user_profile.py +++ b/app/main/views/user_profile.py @@ -11,7 +11,6 @@ url_for, ) from flask_login import current_user -from notifications_utils.url_safe_token import check_token from app import user_api_client from app.event_handlers import ( @@ -31,6 +30,7 @@ ) from app.models.user import User from app.utils.user import user_is_gov_user, user_is_logged_in +from notifications_utils.url_safe_token import check_token NEW_EMAIL = "new-email" NEW_MOBILE = "new-mob" @@ -189,32 +189,19 @@ def user_profile_mobile_number_delete(): @main.route("/user-profile/mobile-number/authenticate", methods=["GET", "POST"]) @user_is_logged_in def user_profile_mobile_number_authenticate(): - # Validate password for form - def _check_password(pwd): - return user_api_client.verify_password(current_user.id, pwd) - - form = ConfirmPasswordForm(_check_password) if NEW_MOBILE not in session: return redirect(url_for(".user_profile_mobile_number")) - if form.validate_on_submit(): - session[NEW_MOBILE_PASSWORD_CONFIRMED] = True - current_user.send_verify_code(to=session[NEW_MOBILE]) - create_mobile_number_change_event( - user_id=current_user.id, - updated_by_id=current_user.id, - original_mobile_number=current_user.mobile_number, - new_mobile_number=session[NEW_MOBILE], - ) - return redirect(url_for(".user_profile_mobile_number_confirm")) - - return render_template( - "views/user-profile/authenticate.html", - thing="mobile number", - form=form, - back_link=url_for(".user_profile_mobile_number_confirm"), + session[NEW_MOBILE_PASSWORD_CONFIRMED] = True + current_user.send_verify_code(to=session[NEW_MOBILE]) + create_mobile_number_change_event( + user_id=current_user.id, + updated_by_id=current_user.id, + original_mobile_number=current_user.mobile_number, + new_mobile_number=session[NEW_MOBILE], ) + return redirect(url_for(".user_profile_mobile_number_confirm")) @main.route("/user-profile/mobile-number/confirm", methods=["GET", "POST"]) diff --git a/app/main/views/verify.py b/app/main/views/verify.py index dc530bce9c..be0a0532d4 100644 --- a/app/main/views/verify.py +++ b/app/main/views/verify.py @@ -2,14 +2,13 @@ from flask import abort, current_app, flash, redirect, render_template, session, url_for from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import check_token from app import user_api_client from app.main import main from app.main.forms import TwoFactorForm from app.models.user import User -from app.notify_client import service_api_client from app.utils.login import redirect_to_sign_in +from notifications_utils.url_safe_token import check_token @main.route("/verify", methods=["GET", "POST"]) @@ -39,6 +38,7 @@ def verify_email(token): current_app.config["EMAIL_EXPIRY_SECONDS"], ) except SignatureExpired: + current_app.logger.error("Email link expired #notify-admin-1505") flash( "The link in the email we sent you has expired. We've sent you a new one." ) @@ -51,6 +51,9 @@ def verify_email(token): abort(404) if user.is_active: + current_app.logger.error( + f"User is using an invite link but is already logged in {user.id} #notify-admin-1505" + ) flash("That verification link has expired.") return redirect(url_for("main.sign_in")) @@ -60,43 +63,15 @@ def verify_email(token): user.send_verify_code() session["user_details"] = {"email": user.email_address, "id": user.id} + current_app.logger.info(f"Email verified for user {user.id} #notify-admin-1505") return redirect(url_for("main.verify")) def activate_user(user_id): user = User.from_id(user_id) - # This is the login.gov path - try: - login_gov_invite_data = service_api_client.retrieve_service_invite_data( - f"service-invite-{user.email_address}" - ) - except BaseException: # noqa - # We will hit an exception if we can't find invite data, - # but that will be the normal sign in use case - login_gov_invite_data = None - if login_gov_invite_data: - login_gov_invite_data = json.loads(login_gov_invite_data) - service_id = login_gov_invite_data["service_id"] - user_id = user_id - permissions = login_gov_invite_data["permissions"] - folder_permissions = login_gov_invite_data["folder_permissions"] - - # Actually call the back end and add the user to the service - try: - user_api_client.add_user_to_service( - service_id, user_id, permissions, folder_permissions - ) - except BaseException as be: # noqa - # TODO if the user is already part of service we should ignore - current_app.logger.warning(f"Exception adding user to service {be}") - - activated_user = user.activate() - activated_user.login() - return redirect(url_for("main.service_dashboard", service_id=service_id)) - # TODO add org invites back in the new way - # organization_id = redis_client.raw_get( + # organization_id = redis_client.get( # f"organization-invite-{user.email_address}" # ) # user_api_client.add_user_to_organization( @@ -108,18 +83,7 @@ def activate_user(user_id): return redirect(url_for("main.organization_dashboard", org_id=organization_id)) else: activated_user = user.activate() + current_app.logger.info(f"Activated user {user.id} #notify-admin-1505") activated_user.login() - + current_app.logger.info(f"Logged in user {user.id} #notify-admin-1505") return redirect(url_for("main.add_service", first="first")) - - -def _add_invited_user_to_service(invitation): - user = User.from_id(session["user_id"]) - service_id = invitation.service - user.add_to_service( - service_id, - invitation.permissions, - invitation.folder_permissions, - invitation.from_user.id, - ) - return service_id diff --git a/app/models/__init__.py b/app/models/__init__.py index 0c36135194..e9adf75a49 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,7 @@ from abc import abstractmethod from flask import abort + from notifications_utils.serialised_model import ( SerialisedModel, SerialisedModelCollection, diff --git a/app/models/event.py b/app/models/event.py index 3af502ea77..b57f11e73f 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from notifications_utils.formatters import formatted_list - from app.formatters import format_thousands from app.models import ModelList from app.notify_client.service_api_client import service_api_client +from notifications_utils.formatters import formatted_list class Event(ABC): diff --git a/app/models/service.py b/app/models/service.py index e06a1b16d3..e9bcf8a7da 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -1,5 +1,4 @@ from flask import abort, current_app -from notifications_utils.serialised_model import SerialisedModelCollection from werkzeug.utils import cached_property from app.models import JSONModel, SortByNameMixin @@ -15,6 +14,7 @@ from app.notify_client.service_api_client import service_api_client from app.notify_client.template_folder_api_client import template_folder_api_client from app.utils import get_default_sms_sender +from notifications_utils.serialised_model import SerialisedModelCollection class Service(JSONModel, SortByNameMixin): @@ -390,7 +390,7 @@ def data_retention(self): def get_data_retention_item(self, id): return next((dr for dr in self.data_retention if dr["id"] == id), None) - def get_days_of_retention(self, notification_type): + def get_days_of_retention(self, notification_type, number_of_days): return next( ( dr @@ -398,7 +398,10 @@ def get_days_of_retention(self, notification_type): if dr["notification_type"] == notification_type ), {}, - ).get("days_of_retention", current_app.config["ACTIVITY_STATS_LIMIT_DAYS"]) + ).get( + "days_of_retention", + current_app.config["ACTIVITY_STATS_LIMIT_DAYS"].get(number_of_days), + ) @cached_property def organization(self): diff --git a/app/models/user.py b/app/models/user.py index 7e9f10632d..3c96fc42c4 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -24,11 +24,15 @@ def _get_service_id_from_view_args(): - return str(request.view_args.get("service_id", "")) or None + if request and request.view_args: + return str(request.view_args.get("service_id", "")) + return None def _get_org_id_from_view_args(): - return str(request.view_args.get("org_id", "")) or None + if request and request.view_args: + return str(request.view_args.get("org_id", "")) + return None class User(JSONModel, UserMixin): @@ -140,9 +144,6 @@ def set_permissions(self, service_id, permissions, folder_permissions, set_by_id set_by_id=set_by_id, ) - def logged_in_elsewhere(self): - return session.get("current_session_id") != self.current_session_id - def activate(self): if self.is_pending: user_data = user_api_client.activate_user(self.id) @@ -150,6 +151,13 @@ def activate(self): else: return self + def deactivate(self): + if self.is_active: + user_data = user_api_client.deactivate_user(self.id) + return self.__class__(user_data["data"]) + else: + return self + def login(self): login_user(self) session["user_id"] = self.id @@ -196,7 +204,7 @@ def is_gov_user(self): @property def is_authenticated(self): - return not self.logged_in_elsewhere() and super(User, self).is_authenticated + return super(User, self).is_authenticated @property def platform_admin(self): @@ -223,7 +231,9 @@ def has_permissions( if not service_id and not org_id: # we shouldn't have any pages that require permissions, but don't specify a service or organization. # use @user_is_platform_admin for platform admin only pages - raise NotImplementedError + # raise NotImplementedError + current_app.logger.warn(f"VIEW ARGS ARE {request.view_args}") + pass log_msg = f"has_permissions user: {self.id} service: {service_id}" # platform admins should be able to do most things (except eg send messages, or create api keys) @@ -674,10 +684,6 @@ def accept_invite(self): class AnonymousUser(AnonymousUserMixin): - # set the anonymous user so that if a new browser hits us we don't error http://stackoverflow.com/a/19275188 - - def logged_in_elsewhere(self): - return False @property def default_organization(self): diff --git a/app/navigation.py b/app/navigation.py index 3c79598cc7..6ef0907a68 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -123,6 +123,7 @@ class HeaderNavigation(Navigation): "get_billing_report", "get_users_report", "get_daily_volumes", + "download_all_users", "get_daily_sms_provider_volumes", "get_volumes_by_service", "organizations", @@ -153,6 +154,9 @@ def is_selected(self, navigation_item): class MainNavigation(Navigation): mapping = { + "activity": { + "all_jobs_activity", + }, "dashboard": { "conversation", "inbox", diff --git a/app/notify_client/__init__.py b/app/notify_client/__init__.py index 1fc14f8119..8db3425f2a 100644 --- a/app/notify_client/__init__.py +++ b/app/notify_client/__init__.py @@ -1,10 +1,12 @@ -from flask import abort, has_request_context, request +import os + +from flask import abort, current_app, has_request_context, request from flask_login import current_user from notifications_python_client import __version__ from notifications_python_client.base import BaseAPIClient -from notifications_utils.clients.redis import RequestCache from app.extensions import redis_client +from notifications_utils.clients.redis import RequestCache cache = RequestCache(redis_client) @@ -54,16 +56,47 @@ def check_inactive_service(self): ): abort(403) + def is_calling_signin_url(self, arg): + return arg.startswith("('/user") + + def check_inactive_user(self, *args): + still_signing_in = False + + # TODO clean up and add testing etc. + # We really should be checking for exact matches + # and we only want to check the first arg + for arg in args: + arg = str(arg) + if self.is_calling_signin_url(arg): + still_signing_in = True + + # This seems to be a weird edge case that happens intermittently with invites + if str(arg) == "()": + still_signing_in = True + # TODO: Update this once E2E tests are managed by a feature flag or some other main config option. + if os.getenv("NOTIFY_E2E_TEST_EMAIL"): + # allow end-to-end tests to skip check + pass + elif still_signing_in is True: + # we are not full signed in yet + pass + elif not current_user or not current_user.is_active: + current_app.logger.error(f"Unauthorized URL #notify-compliance-46 {args}") + abort(403) + def post(self, *args, **kwargs): self.check_inactive_service() + self.check_inactive_user(args) return super().post(*args, **kwargs) def put(self, *args, **kwargs): self.check_inactive_service() + self.check_inactive_user() return super().put(*args, **kwargs) def delete(self, *args, **kwargs): self.check_inactive_service() + self.check_inactive_user() return super().delete(*args, **kwargs) diff --git a/app/notify_client/job_api_client.py b/app/notify_client/job_api_client.py index 538bdd3709..9a06e16bfa 100644 --- a/app/notify_client/job_api_client.py +++ b/app/notify_client/job_api_client.py @@ -27,9 +27,7 @@ class JobApiClient(NotifyAdminAPIClient): def get_job(self, service_id, job_id): params = {} - job = self.get( - url="/service/{}/job/{}".format(service_id, job_id), params=params - ) + job = self.get(url=f"/service/{service_id}/job/{job_id}", params=params) return job @@ -40,13 +38,14 @@ def get_jobs(self, service_id, *, limit_days=None, statuses=None, page=1): if statuses is not None: params["statuses"] = ",".join(statuses) - return self.get(url="/service/{}/job".format(service_id), params=params) + job = self.get(url=f"/service/{service_id}/job", params=params) + return job def get_uploads(self, service_id, limit_days=None, page=1): params = {"page": page} if limit_days is not None: params["limit_days"] = limit_days - return self.get(url="/service/{}/upload".format(service_id), params=params) + return self.get(url=f"/service/{service_id}/upload", params=params) def has_sent_previously( self, service_id, template_id, template_version, original_file_name diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index d34516b8bd..3a0655d9e9 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from app.extensions import redis_client from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache @@ -43,6 +43,28 @@ def get_service_statistics(self, service_id, limit_days=None): params={"limit_days": limit_days}, )["data"] + def get_service_notification_statistics_by_day( + self, service_id, start_date=None, days=None + ): + if start_date is None: + start_date = datetime.now().strftime("%Y-%m-%d") + + return self.get( + "/service/{0}/statistics/{1}/{2}".format(service_id, start_date, days), + )["data"] + + def get_user_service_notification_statistics_by_day( + self, service_id, user_id, start_date=None, days=None + ): + if start_date is None: + start_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + return self.get( + "/service/{0}/statistics/user/{1}/{2}/{3}".format( + service_id, user_id, start_date, days + ), + )["data"] + def get_services(self, params_dict=None): """ Retrieve a list of services. diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index e852258387..01a3a78c95 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -2,6 +2,7 @@ from notifications_python_client.errors import HTTPError from app.notify_client import NotifyAdminAPIClient, cache +from app.utils import hilite from app.utils.user_permissions import translate_permissions_from_ui_to_db ALLOWED_ATTRIBUTES = { @@ -109,13 +110,14 @@ def verify_password(self, user_id, password): raise def send_verify_code(self, user_id, code_type, to, next_string=None): + data = {"to": to} if next_string: data["next"] = next_string if code_type == "email": data["email_auth_link_host"] = self.admin_url endpoint = "/user/{0}/{1}-code".format(user_id, code_type) - current_app.logger.warn(f"Sending verify_code {code_type} to {user_id}") + current_app.logger.warn(hilite(f"Sending verify_code {code_type} to {user_id}")) self.post(endpoint, data=data) def send_verify_email(self, user_id, to): @@ -157,6 +159,10 @@ def get_all_users(self): endpoint = "/user" return self.get(endpoint)["data"] + def get_all_users_detailed(self): + endpoint = "/user/report-all-users" + return self.get(endpoint)["data"] + @cache.delete("service-{service_id}") @cache.delete("service-{service_id}-template-folders") @cache.delete("user-{user_id}") @@ -217,6 +223,10 @@ def find_users_by_full_or_partial_email(self, email_address): def activate_user(self, user_id): return self.post("/user/{}/activate".format(user_id), data=None) + @cache.delete("user-{user_id}") + def deactivate_user(self, user_id): + return self.post("/user/{}/deactivate".format(user_id), data=None) + def send_change_email_verification(self, user_id, new_email): endpoint = "/user/{}/change-email-verification".format(user_id) data = {"email": new_email} diff --git a/app/s3_client/__init__.py b/app/s3_client/__init__.py index e0933b4644..7de3509d2f 100644 --- a/app/s3_client/__init__.py +++ b/app/s3_client/__init__.py @@ -1,3 +1,5 @@ +import os + import botocore from boto3 import Session from botocore.config import Config @@ -29,6 +31,17 @@ def get_s3_object( ) s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) obj = s3.Object(bucket_name, filename) + # This 'proves' that use of moto in the relevant tests in test_send.py + # mocks everything related to S3. What you will see in the logs is: + # Exception: CREATED AT + # + # raise Exception(f"CREATED AT {_s3.Bucket(bucket_name).creation_date}") + if os.getenv("NOTIFY_ENVIRONMENT") == "test": + teststr = str(s3.Bucket(bucket_name).creation_date).lower() + if "magicmock" not in teststr: + raise Exception( + "Test is not mocked, use @mock_aws or the relevant mocker.patch to avoid accessing S3" + ) return obj diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py index d3be10b948..752f054a45 100644 --- a/app/s3_client/s3_csv_client.py +++ b/app/s3_client/s3_csv_client.py @@ -1,7 +1,6 @@ import uuid from flask import current_app -from notifications_utils.s3 import s3upload as utils_s3upload from app.s3_client import ( get_s3_contents, @@ -9,6 +8,7 @@ get_s3_object, set_s3_metadata, ) +from notifications_utils.s3 import s3upload as utils_s3upload FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv" @@ -28,6 +28,7 @@ def get_csv_upload(service_id, upload_id): def s3upload(service_id, filedata): + upload_id = str(uuid.uuid4()) bucket_name, file_location, access_key, secret_key, region = get_csv_location( service_id, upload_id diff --git a/app/s3_client/s3_logo_client.py b/app/s3_client/s3_logo_client.py index e27218fefe..5a8453ad13 100644 --- a/app/s3_client/s3_logo_client.py +++ b/app/s3_client/s3_logo_client.py @@ -2,9 +2,9 @@ from boto3 import Session from flask import current_app -from notifications_utils.s3 import s3upload as utils_s3upload from app.s3_client import get_s3_object +from notifications_utils.s3 import s3upload as utils_s3upload TEMP_TAG = "temp-{user_id}_" EMAIL_LOGO_LOCATION_STRUCTURE = "{temp}{unique_id}-{filename}" diff --git a/app/templates/new/base.html b/app/templates/base.html similarity index 78% rename from app/templates/new/base.html rename to app/templates/base.html index 95039ce4f0..4a6421afbf 100644 --- a/app/templates/new/base.html +++ b/app/templates/base.html @@ -1,10 +1,17 @@ -{% from "../components/banner.html" import banner %} -{% from "../components/components/skip-link/macro.njk" import usaSkipLink -%} - +{% from "components/banner.html" import banner %} +{% from "components/components/skip-link/macro.njk" import usaSkipLink -%} +{% from "components/sub-navigation.html" import sub_navigation %} - {% include "new/components/head.html" %} + + + {% block pageTitle %} + {% block per_page_title %}{% endblock %} – Notify.gov + {% endblock %} + + {% include "new/components/head.html" %} + {% block bodyStart %} @@ -23,11 +30,10 @@ }) }} {% endblock %} + {% block header %} - {% if current_user.is_authenticated %} - {% include 'new/components/usa_banner.html' %} - {% include 'new/components/header.html' %} - {% endif %} + {% include 'new/components/usa_banner.html' %} + {% include 'new/components/header.html' %} {% endblock %} {% block main %} @@ -36,18 +42,17 @@ {% block backLink %}{% endblock %} {% endblock %} {% block mainClasses %} - + {% set mainClasses = "margin-top-5 padding-bottom-5" %}
{% endblock %} {% block content %} {% block flash_messages %} - + {% include 'new/components/flash_messages.html' %} {% endblock %} {% block maincolumn_content %} - {% block fromContentTemplatetwoColumnGrid %}
{% if navigation_links %} -
+
{{ sub_navigation(navigation_links) }}
@@ -57,8 +62,6 @@ {% block content_column_content %}{% endblock %}
- - {% endblock %} {% endblock %} {% endblock %}
diff --git a/app/templates/components/table.html b/app/templates/components/table.html index dab53fc1f8..e0dd347d8a 100644 --- a/app/templates/components/table.html +++ b/app/templates/components/table.html @@ -6,7 +6,7 @@ {% for field_heading in field_headings %} - + {% if field_headings_visible %} {{ field_heading }} {% else %} @@ -79,9 +79,9 @@ {%- endmacro %} {% macro row_heading() -%} - + {{ caller() }} - + {%- endmacro %} {% macro index_field(text=None, rowspan=None) -%} diff --git a/app/templates/error/401.html b/app/templates/error/401.html index 01cad95e31..0562598b51 100644 --- a/app/templates/error/401.html +++ b/app/templates/error/401.html @@ -1,4 +1,4 @@ -{% extends "withoutnav_template.html" %} +{% extends "base.html" %} {% block per_page_title %}You’re not authorised to see this page{% endblock %} {% block maincolumn_content %}
diff --git a/app/templates/error/403.html b/app/templates/error/403.html index be2c12acc6..4bad0f94da 100644 --- a/app/templates/error/403.html +++ b/app/templates/error/403.html @@ -1,4 +1,4 @@ -{% extends "withoutnav_template.html" %} +{% extends "base.html" %} {% block per_page_title %}You’re not allowed to see this page{% endblock %} {% block maincolumn_content %}
diff --git a/app/templates/error/404.html b/app/templates/error/404.html index bb27fc4036..f03e4d044f 100644 --- a/app/templates/error/404.html +++ b/app/templates/error/404.html @@ -1,4 +1,4 @@ -{% extends "withoutnav_template.html" %} +{% extends "base.html" %} {% block per_page_title %}Page not found{% endblock %} {% block maincolumn_content %}
diff --git a/app/templates/error/410.html b/app/templates/error/410.html index bb27fc4036..f03e4d044f 100644 --- a/app/templates/error/410.html +++ b/app/templates/error/410.html @@ -1,4 +1,4 @@ -{% extends "withoutnav_template.html" %} +{% extends "base.html" %} {% block per_page_title %}Page not found{% endblock %} {% block maincolumn_content %}
diff --git a/app/templates/error/500.html b/app/templates/error/500.html index aca8332df9..d76a8759cb 100644 --- a/app/templates/error/500.html +++ b/app/templates/error/500.html @@ -1,4 +1,4 @@ -{% extends "withoutnav_template.html" %} +{% extends "base.html" %} {% block per_page_title %}Sorry, there’s a problem with the service{% endblock %} {% block maincolumn_content %}
diff --git a/app/templates/flash_messages.html b/app/templates/new/components/flash_messages.html similarity index 100% rename from app/templates/flash_messages.html rename to app/templates/new/components/flash_messages.html diff --git a/app/templates/new/components/footer.html b/app/templates/new/components/footer.html index 45e6525a7b..ebc8ebbcca 100644 --- a/app/templates/new/components/footer.html +++ b/app/templates/new/components/footer.html @@ -58,6 +58,20 @@

Support links

+
+
+ + + +
+
+
diff --git a/app/templates/new/components/head.html b/app/templates/new/components/head.html index 9ceb9dbc19..3e99f73f59 100644 --- a/app/templates/new/components/head.html +++ b/app/templates/new/components/head.html @@ -1,6 +1,4 @@ - - {% block pageTitle %}Notify.gov{% endblock %} @@ -12,35 +10,27 @@ {# Ensure that older IE versions always render with the correct rendering engine #} - {% block headIcons %} - - - - - + + + + + + - + - {% endblock %} {% block extra_stylesheets %}{% endblock %} - {% block meta_format_detection %} - {% endblock %} - {% block og_image %} - {% endblock %} {# google #} {% if g.hide_from_search_engines %} {% endif %} - - {% block head %}{% endblock %} - diff --git a/app/templates/new/components/header.html b/app/templates/new/components/header.html index e21963fed5..2b0a384e94 100644 --- a/app/templates/new/components/header.html +++ b/app/templates/new/components/header.html @@ -1,25 +1,35 @@ {# setting navigation and secondarynavigation #} -{% set navigation = [ - {"href": url_for("main.show_accounts_or_dashboard"), "text": "Current service", "active": header_navigation.is_selected('accounts-or-dashboard')}, - {"href": url_for('main.get_started'), "text": "Using Notify", "active": header_navigation.is_selected('using_notify')}, - {"href": url_for('main.features'), "text": "Features", "active": header_navigation.is_selected('features')}, - {"href": url_for('main.support'), "text": "Contact us", "active": header_navigation.is_selected('support')} -] %} +{% if current_user.is_authenticated %} + {% set navigation = [ + {"href": url_for("main.show_accounts_or_dashboard"), "text": "Current service", "active": header_navigation.is_selected('accounts-or-dashboard')}, + {"href": url_for('main.get_started'), "text": "Using Notify", "active": header_navigation.is_selected('using_notify')}, + {"href": url_for('main.features'), "text": "Features", "active": header_navigation.is_selected('features')}, + {"href": url_for('main.support'), "text": "Contact us", "active": header_navigation.is_selected('support')} + ] %} -{% if current_user.platform_admin %} - {% set navigation = navigation + [{"href": url_for('main.platform_admin_splash_page'), "text": "Platform admin", "active": header_navigation.is_selected('platform-admin')}] %} -{% else %} - {% set navigation = navigation + [{"href": url_for('main.user_profile'), "text": "User profile", "active": header_navigation.is_selected('user-profile')}] %} -{% endif %} + {% if current_user.platform_admin %} + {% set navigation = navigation + [{"href": url_for('main.platform_admin_splash_page'), "text": "Platform admin", "active": header_navigation.is_selected('platform-admin')}] %} + {% else %} + {% set navigation = navigation + [{"href": url_for('main.user_profile'), "text": "User profile", "active": header_navigation.is_selected('user-profile')}] %} + {% endif %} -{% if current_service %} - {% set secondaryNavigation = [ - {"href": url_for('main.service_settings', service_id=current_service.id), "text": "Settings", "active": secondary_navigation.is_selected('settings')}, - {"href": url_for('main.sign_out'), "text": "Sign out"} - ] %} -{% else %} - {% set secondaryNavigation = [{"href": url_for('main.sign_out'), "text": "Sign out"}] %} + {% if current_service %} + {% if current_user.has_permissions('manage_service') %} + {% set secondaryNavigation = [ + {"href": url_for('main.service_settings', service_id=current_service.id), "text": "Settings", "active": secondary_navigation.is_selected('settings')}, + {"href": url_for('main.sign_out'), "text": "Sign out"} + ] %} + {% else %} + {% set secondaryNavigation = [ + {"href": url_for('main.sign_out'), "text": "Sign out"} + ] %} + + {% endif %} + {% else %} + {% set secondaryNavigation = [{"href": url_for('main.sign_out'), "text": "Sign out"}] %} + {% endif %} {% endif %} + {# usa header #}
@@ -28,7 +38,7 @@ @@ -40,7 +50,7 @@