diff --git a/FrontEnd/main.scss b/FrontEnd/main.scss index a3f98f559..8259c76b7 100644 --- a/FrontEnd/main.scss +++ b/FrontEnd/main.scss @@ -38,6 +38,7 @@ $mobile-breakpoint: 740px; @import 'styles/package_list'; @import 'styles/package'; @import 'styles/panel_button'; +@import 'styles/portal'; @import 'styles/readme'; @import 'styles/search_results'; @import 'styles/search'; diff --git a/FrontEnd/styles/header_footer.scss b/FrontEnd/styles/header_footer.scss index fd6d4dba7..ceca69922 100644 --- a/FrontEnd/styles/header_footer.scss +++ b/FrontEnd/styles/header_footer.scss @@ -36,6 +36,7 @@ footer { display: flex; flex-direction: row; flex-wrap: wrap; + align-items: center; justify-content: center; margin: 0; padding: 0; @@ -91,6 +92,13 @@ header { border-color: var(--header-link-highlight); } } + + li.portal { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } } @media screen and (max-width: $mobile-breakpoint) { diff --git a/FrontEnd/styles/images.scss b/FrontEnd/styles/images.scss index fd6dac4b4..413320efc 100644 --- a/FrontEnd/styles/images.scss +++ b/FrontEnd/styles/images.scss @@ -17,12 +17,14 @@ // ------------------------------------------------------------------------- :root { + --image-account: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTM3LjYxIDEzLjc1YzAtNi45Ni01LjY1LTEyLjYxLTEyLjYxLTEyLjYxcy0xMi42MSA1LjY1LTEyLjYxIDEyLjYxYzAgMy45NSAxLjgxIDcuNDYgNC42NSA5Ljc4LTUuODQgNC4yOC0xMC4zNiAxMy4xMi0xMi4xNyAyNC4wNCA2LjM4Ljg0IDEzLjA4IDEuMyAyMCAxLjNzMTMuNzktLjQ3IDIwLjI1LTEuMzRjLTEuODEtMTAuOS02LjMzLTE5LjczLTEyLjE2LTI0LjAxIDIuODQtMi4zMSA0LjY1LTUuODMgNC42NS05Ljc4eiIgZmlsbD0iIzJmMmYyZiIvPjwvc3ZnPg=='); --image-activity: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQwIDEuNWgtMzBjLTQuOTUgMC05IDQuMDUtOSA5djE4YzAgNC45NSA0LjA1IDkgOSA5aDE5LjUxYy40OC42Mi45NCAxLjI4IDEuNDEgMiAyLjYgNC4wMiAyLjgyIDYuMjMgMS4wOSA5IDYuOTItMi43NyA5LjMtNC45OCAxMS45LTkgMS4wOC0xLjY3IDEuNzMtMy4wMyAyLjAyLTQuMjUgMS44OC0xLjY1IDMuMDgtNC4wNyAzLjA4LTYuNzV2LTE4YzAtNC45NS00LjA1LTktOS05eiIgZmlsbD0iIzJmMmYyZiIvPjwvc3ZnPg=='); --image-authors: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzJmMmYyZiI+PGNpcmNsZSBjeD0iMjUiIGN5PSIxMy4zOCIgcj0iMTIuMzgiLz48cGF0aCBkPSJtNDkgNDljMC04LjY3LTMuNzEtMjMuMDUtMTAuNDYtMjcuNDctMi42OCA0LjctNy43MyA3Ljg4LTEzLjU0IDcuODhzLTEwLjg1LTMuMTgtMTMuNTQtNy44OGMtNi43NSA0LjQyLTEwLjQ2IDE4LjgtMTAuNDYgMjcuNDciLz48L2c+PC9zdmc+'); --image-beta: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAzOGMzMyI+PHBhdGggZD0ibTI0Ljg4IDI0LjUyaC0uNzN2LTMuNzNoLjQ5Yy41OCAwIDEuMDMtLjIgMS4zNS0uNTlzLjQ3LS45Mi40Ny0xLjU5LS4xOC0xLjE4LS41NC0xLjU0LS44My0uNTQtMS40MS0uNTRjLS43MyAwLTEuMjYuMjMtMS42LjY5cy0uNTEgMS4xNS0uNTEgMi4wN3Y5LjI1Yy4yNi4xOS42My4zNSAxLjEyLjQ4cy45My4yIDEuMzMuMmMuODIgMCAxLjQ3LS4yMSAxLjk0LS42MnMuNzEtMS4wNC43MS0xLjg3YzAtLjY3LS4yMy0xLjIxLS42OS0xLjYxcy0xLjExLS42LTEuOTQtLjZ6Ii8+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzUtMjQtMjQtMjR6bTUuOTIgMzAuNTRjLTEuMTIgMS4wNy0yLjYyIDEuNjEtNC40OSAxLjYxLS44MiAwLTEuNTktLjA3LTIuMy0uMjFzLTEuMjktLjMyLTEuNzMtLjU0djYuODFoLTUuMDF2LTIwLjM3YzAtMS45Mi42NC0zLjQgMS45Mi00LjQ2czMuMDYtMS41OCA1LjM1LTEuNThjMi4xMiAwIDMuNzguNDUgNC45OCAxLjM1czEuOCAyLjE0IDEuOCAzLjczYzAgMS4yNy0uMzEgMi4yNi0uOTQgMi45OXMtMS41MiAxLjE4LTIuNjYgMS4zN3YuMWMxLjY0LjIxIDIuODUuNzIgMy42MSAxLjUzLjc3LjgxIDEuMTUgMS45MiAxLjE1IDMuMzMgMCAxLjgyLS41NiAzLjI3LTEuNjggNC4zNHoiLz48L2c+PC9zdmc+'); --image-branch: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAyNzM1MyI+PHBhdGggZD0ibTIyIDM3LjM4Yy01LjU4LTEuMzUtOS43NS02LjM4LTkuNzUtMTIuMzhzNC4xNy0xMS4wMiA5Ljc1LTEyLjM4di0xMS40M2MtMTEuODQgMS40OC0yMSAxMS41Ny0yMSAyMy44MXM5LjE2IDIyLjMzIDIxIDIzLjgxeiIvPjxwYXRoIGQ9Im0yOCAxMi42MmM1LjU4IDEuMzUgOS43NSA2LjM4IDkuNzUgMTIuMzggMCAyLjEyLS41MyA0LjExLTEuNDQgNS44N2w2LjY5IDYuNjl2My4zMWMzLjczLTQuMjMgNi05Ljc4IDYtMTUuODcgMC0xMi4yNC05LjE2LTIyLjMzLTIxLTIzLjgxdjExLjQzeiIvPjxjaXJjbGUgY3g9IjI1IiBjeT0iMjUiIHI9IjYuNzUiLz48cGF0aCBkPSJtMzcgNDAuMDQtNC42NS00LjY1Yy0xLjI5LjkyLTIuNzcgMS42LTQuMzUgMS45OHYxMS40M2MzLjI1LS40MSA2LjI5LTEuNDYgOS0zLjAzdi01Ljc0eiIvPjwvZz48L3N2Zz4='); --image-build-failed: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQ4Ljk1IDEwLjk1LTkuOS05LjktMTQuMDUgMTQuMDUtMTQuMDUtMTQuMDUtOS45IDkuOSAxNC4wNSAxNC4wNS0xNC4wNSAxNC4wNSA5LjkgOS45IDE0LjA1LTE0LjA1IDE0LjA1IDE0LjA1IDkuOS05LjktMTQuMDUtMTQuMDV6IiBmaWxsPSIjYzQ0Ii8+PC9zdmc+'); --image-build-succeeded: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTE4LjMyIDQ0Ljg5LTE3LjU4LTE3LjU4IDguODUtOC44NCA4LjczIDguNzMgMjIuMDktMjIuMDkgOC44NSA4Ljg0eiIgZmlsbD0iIzY4YmIxMyIvPjwvc3ZnPg=='); + --image-checkered-flag-cta: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTE3LjIzIDIwLjQ1Yy00LjU3IDEuNTctOS4yOCAyLjQtMTQuMzggMS4wMS0uNjUtMy43LTEuMy03LjM5LTEuOTYtMTEuMDkgNS4xIDEuMzkgOS44LjU2IDE0LjM4LTEuMDEuNjUgMy43IDEuMyA3LjM5IDEuOTYgMTEuMDl6IiBmaWxsPSIjMmYyZjJmIi8+PHBhdGggZD0ibTMwLjgyIDE1LjAxYy00LjU3IDEuNTctOS4wMiAzLjg4LTEzLjU5IDUuNDUtLjY1LTMuNy0xLjMtNy4zOS0xLjk2LTExLjA5IDQuNTctMS41NyA5LjAyLTMuODggMTMuNTktNS40NS42NSAzLjcgMS4zIDcuMzkgMS45NiAxMS4wOXoiIGZpbGw9IiNkYWRhZGEiLz48cGF0aCBkPSJtNDUuMiAxNGMtNS4xLTEuMzktOS44LS41Ni0xNC4zOCAxLjAxLS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA0LjU3LTEuNTcgOS4yOC0yLjQgMTQuMzgtMS4wMS42NSAzLjcgMS4zIDcuMzkgMS45NiAxMS4wOXoiIGZpbGw9IiMyZjJmMmYiLz48cGF0aCBkPSJtMTkuMTggMzEuNTVjLTQuNTcgMS41Ny05LjI4IDIuNC0xNC4zOCAxLjAxLS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA1LjEgMS4zOSA5LjguNTYgMTQuMzgtMS4wMS42NSAzLjcgMS4zIDcuMzkgMS45NiAxMS4wOXoiIGZpbGw9IiNkYWRhZGEiLz48cGF0aCBkPSJtMzIuNzcgMjYuMWMtNC41NyAxLjU3LTkuMDIgMy44OC0xMy41OSA1LjQ1LS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA0LjU3LTEuNTcgOS4wMi0zLjg4IDEzLjU5LTUuNDUuNjUgMy43IDEuMyA3LjM5IDEuOTYgMTEuMDl6IiBmaWxsPSIjMmYyZjJmIi8+PHBhdGggZD0ibTQ3LjE1IDI1LjA5Yy01LjEtMS4zOS05LjgtLjU2LTE0LjM4IDEuMDEtLjY1LTMuNy0xLjMtNy4zOS0xLjk2LTExLjA5IDQuNTctMS41NyA5LjI4LTIuNCAxNC4zOC0xLjAxLjY1IDMuNyAxLjMgNy4zOSAxLjk2IDExLjA5eiIgZmlsbD0iI2RhZGFkYSIvPjxwYXRoIGQ9Im0zNC43MyAzNy4xOWMtNC41NyAxLjU3LTkuMDIgMy44OC0xMy41OSA1LjQ1LS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA0LjU3LTEuNTcgOS4wMi0zLjg4IDEzLjU5LTUuNDUuNjUgMy43IDEuMyA3LjM5IDEuOTYgMTEuMDl6IiBmaWxsPSIjZGFkYWRhIi8+PGcgZmlsbD0iIzJmMmYyZiI+PHBhdGggZD0ibTQ5LjExIDM2LjE4Yy01LjEtMS4zOS05LjgtLjU2LTE0LjM4IDEuMDEtLjY1LTMuNy0xLjMtNy4zOS0xLjk2LTExLjA5IDQuNTctMS41NyA5LjI4LTIuNCAxNC4zOC0xLjAxLjY1IDMuNyAxLjMgNy4zOSAxLjk2IDExLjA5eiIvPjxwYXRoIGQ9Im00LjggMzIuNTYgMS41MyA4LjY3LjQzIDIuNDIuNzMgNC4xNSAzLjc2LS42Ni0uNS0yLjgzYzMuNi4yMyA3LjAyLS41MiAxMC4zOC0xLjY3LS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOS00LjU3IDEuNTctOS4yOCAyLjQtMTQuMzggMS4wMXoiLz48L2c+PC9zdmc+'); --image-checkered-flag: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzJmMmYyZiI+PHBhdGggZD0ibTE4Ljk1IDE4LjUyYy41IDIuODYgMS4wMSA1LjcyIDEuNTEgOC41OCAzLjU2LTEuMzMgNy4wNC0zLjA5IDEwLjU5LTQuNDEtLjUtMi44Ni0xLjAxLTUuNzItMS41MS04LjU4LTMuNTYgMS4zMy03LjA0IDMuMDktMTAuNTkgNC40MXoiLz48cGF0aCBkPSJtMzguNTYuNzVjLTExLjU4LTEuMTctMjEuMTEgOS4yNi0zMi42OSA4LjA4LTEuMDMtLjEtMi4wNy0uMy0zLjE0LS42MWwuNTIgMi45NCAzLjAzIDE3LjE2IDEuNTEgOC41OC41MiAyLjk0IDEuNjcgOS40OSAyLjk0LS41Mi0xLjQ4LTguMzdjMTEuNTggMS4xOCAyMS4xMS05LjI2IDMyLjY5LTguMDggMS4wMy4xIDIuMDcuMyAzLjE0LjYxbC0uNTItMi45NGMtMS41MS04LjU4LTMuMDMtMTcuMTYtNC41NC0yNS43NGwtLjUyLTIuOTRjLTEuMDYtLjMtMi4xMS0uNS0zLjE0LS42MXptMy41NCAyMC4xYy0zLjg2LS4zOS03LjQ5LjUxLTExLjA1IDEuODNsMS41MSA4LjU4Yy0zLjU2IDEuMzMtNy4wNCAzLjA4LTEwLjYgNC40MWwtMS41MS04LjU4Yy0zLjU2IDEuMzMtNy4xOSAyLjIzLTExLjA1IDEuODNsLS4yLTEuMTJjLS40NC0yLjQ4LS44OC00Ljk3LTEuMzEtNy40NSAzLjg2LjM5IDcuNDktLjUxIDExLjA1LTEuODMtLjQ3LTIuNjctMS4wMy01Ljg0LTEuNTEtOC41OCAzLjU2LTEuMzMgNy4wNC0zLjA4IDEwLjYtNC40MS40OCAyLjczIDEuMDUgNS45NCAxLjUxIDguNTggMy41Ni0xLjMzIDcuMTktMi4yMyAxMS4wNS0xLjgzbDEuNTEgOC41OHoiLz48L2c+PC9zdmc+'); --image-clear-search: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzQtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzQtMjQtMjQtMjR6bTEzLjg4IDMyLjE0LTUuNzQgNS43My04LjE0LTguMTQtOC4xNCA4LjE0LTUuNzMtNS43MyA4LjE0LTguMTQtOC4xNC04LjE0IDUuNzMtNS43NCA4LjE0IDguMTQgOC4xNC04LjE0IDUuNzQgNS43NC04LjE0IDguMTR6IiBmaWxsPSIjZGFkYWRhIi8+PC9zdmc+'); --image-compatibility-unknown: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTE5LjE3IDMxLjF2LTIuNDZjMC0xLjg3LjM4LTMuNDUgMS4xNS00Ljc0Ljc2LTEuMjkgMi4xMi0yLjU0IDQuMDctMy43NiAxLjU1LS45NyAyLjY2LTEuODUgMy4zNC0yLjYzczEuMDItMS42OCAxLjAyLTIuNjljMC0uOC0uMzYtMS40NC0xLjA5LTEuOS0uNzMtLjQ3LTEuNjctLjctMi44My0uNy0yLjg4IDAtNi4yNSAxLjAyLTEwLjExIDMuMDZsLTMuOTgtNy43OWM0Ljc1LTIuNzEgOS43MS00LjA3IDE0Ljg5LTQuMDcgNC4yNiAwIDcuNi45NCAxMC4wMiAyLjgxczMuNjQgNC40MiAzLjY0IDcuNjRjMCAyLjMxLS41NCA0LjMxLTEuNjIgNnMtMi44IDMuMjktNS4xNyA0LjhjLTIgMS4zLTMuMjYgMi4yNS0zLjc2IDIuODUtLjUxLjYtLjc2IDEuMzEtLjc2IDIuMTN2MS40NmgtOC43OXptLTEuMjMgMTAuMDJjMC0xLjc2LjUxLTMuMTEgMS41My00LjA2czIuNTItMS40MyA0LjUxLTEuNDMgMy4zNy40OCA0LjM5IDEuNDUgMS41MyAyLjMxIDEuNTMgNC4wNS0uNTMgMy4wOC0xLjU5IDQuMDRjLTEuMDYuOTUtMi41MSAxLjQzLTQuMzQgMS40M3MtMy4zNy0uNDctNC40NC0xLjQyLTEuNi0yLjI5LTEuNi00LjA1eiIgZmlsbD0iI2RhZGFkYSIvPjwvc3ZnPg=='); @@ -57,12 +59,14 @@ @media (prefers-color-scheme: dark) { :root { + --image-account: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTM3LjYxIDEzLjc1YzAtNi45Ni01LjY1LTEyLjYxLTEyLjYxLTEyLjYxcy0xMi42MSA1LjY1LTEyLjYxIDEyLjYxYzAgMy45NSAxLjgxIDcuNDYgNC42NSA5Ljc4LTUuODQgNC4yOC0xMC4zNiAxMy4xMi0xMi4xNyAyNC4wNCA2LjM4Ljg0IDEzLjA4IDEuMyAyMCAxLjNzMTMuNzktLjQ3IDIwLjI1LTEuMzRjLTEuODEtMTAuOS02LjMzLTE5LjczLTEyLjE2LTI0LjAxIDIuODQtMi4zMSA0LjY1LTUuODMgNC42NS05Ljc4eiIgZmlsbD0iI2YxZjFmMSIvPjwvc3ZnPg=='); --image-activity: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQwIDEuNWgtMzBjLTQuOTUgMC05IDQuMDUtOSA5djE4YzAgNC45NSA0LjA1IDkgOSA5aDE5LjUxYy40OC42Mi45NCAxLjI4IDEuNDEgMiAyLjYgNC4wMiAyLjgyIDYuMjMgMS4wOSA5IDYuOTItMi43NyA5LjMtNC45OCAxMS45LTkgMS4wOC0xLjY3IDEuNzMtMy4wMyAyLjAyLTQuMjUgMS44OC0xLjY1IDMuMDgtNC4wNyAzLjA4LTYuNzV2LTE4YzAtNC45NS00LjA1LTktOS05eiIgZmlsbD0iI2YxZjFmMSIvPjwvc3ZnPg=='); --image-authors: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iI2YxZjFmMSI+PGNpcmNsZSBjeD0iMjUiIGN5PSIxMy4zOCIgcj0iMTIuMzgiLz48cGF0aCBkPSJtNDkgNDljMC04LjY3LTMuNzEtMjMuMDUtMTAuNDYtMjcuNDctMi42OCA0LjctNy43MyA3Ljg4LTEzLjU0IDcuODhzLTEwLjg1LTMuMTgtMTMuNTQtNy44OGMtNi43NSA0LjQyLTEwLjQ2IDE4LjgtMTAuNDYgMjcuNDciLz48L2c+PC9zdmc+'); --image-beta: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzBlYmY0YyI+PHBhdGggZD0ibTI0Ljg4IDI0LjUyaC0uNzN2LTMuNzNoLjQ5Yy41OCAwIDEuMDMtLjIgMS4zNS0uNTlzLjQ3LS45Mi40Ny0xLjU5LS4xOC0xLjE4LS41NC0xLjU0LS44My0uNTQtMS40MS0uNTRjLS43MyAwLTEuMjYuMjMtMS42LjY5cy0uNTEgMS4xNS0uNTEgMi4wN3Y5LjI1Yy4yNi4xOS42My4zNSAxLjEyLjQ4cy45My4yIDEuMzMuMmMuODIgMCAxLjQ3LS4yMSAxLjk0LS42MnMuNzEtMS4wNC43MS0xLjg3YzAtLjY3LS4yMy0xLjIxLS42OS0xLjYxcy0xLjExLS42LTEuOTQtLjZ6Ii8+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzUtMjQtMjQtMjR6bTUuOTIgMzAuNTRjLTEuMTIgMS4wNy0yLjYyIDEuNjEtNC40OSAxLjYxLS44MiAwLTEuNTktLjA3LTIuMy0uMjFzLTEuMjktLjMyLTEuNzMtLjU0djYuODFoLTUuMDF2LTIwLjM3YzAtMS45Mi42NC0zLjQgMS45Mi00LjQ2czMuMDYtMS41OCA1LjM1LTEuNThjMi4xMiAwIDMuNzguNDUgNC45OCAxLjM1czEuOCAyLjE0IDEuOCAzLjczYzAgMS4yNy0uMzEgMi4yNi0uOTQgMi45OXMtMS41MiAxLjE4LTIuNjYgMS4zN3YuMWMxLjY0LjIxIDIuODUuNzIgMy42MSAxLjUzLjc3LjgxIDEuMTUgMS45MiAxLjE1IDMuMzMgMCAxLjgyLS41NiAzLjI3LTEuNjggNC4zNHoiLz48L2c+PC9zdmc+'); --image-branch: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzBjOTQ2ZSI+PHBhdGggZD0ibTIyIDM3LjM4Yy01LjU4LTEuMzUtOS43NS02LjM4LTkuNzUtMTIuMzhzNC4xNy0xMS4wMiA5Ljc1LTEyLjM4di0xMS40M2MtMTEuODQgMS40OC0yMSAxMS41Ny0yMSAyMy44MXM5LjE2IDIyLjMzIDIxIDIzLjgxeiIvPjxwYXRoIGQ9Im0yOCAxMi42MmM1LjU4IDEuMzUgOS43NSA2LjM4IDkuNzUgMTIuMzggMCAyLjEyLS41MyA0LjExLTEuNDQgNS44N2w2LjY5IDYuNjl2My4zMWMzLjczLTQuMjMgNi05Ljc4IDYtMTUuODcgMC0xMi4yNC05LjE2LTIyLjMzLTIxLTIzLjgxdjExLjQzeiIvPjxjaXJjbGUgY3g9IjI1IiBjeT0iMjUiIHI9IjYuNzUiLz48cGF0aCBkPSJtMzcgNDAuMDQtNC42NS00LjY1Yy0xLjI5LjkyLTIuNzcgMS42LTQuMzUgMS45OHYxMS40M2MzLjI1LS40MSA2LjI5LTEuNDYgOS0zLjAzdi01Ljc0eiIvPjwvZz48L3N2Zz4='); --image-build-failed: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQ4Ljk1IDEwLjk1LTkuOS05LjktMTQuMDUgMTQuMDUtMTQuMDUtMTQuMDUtOS45IDkuOSAxNC4wNSAxNC4wNS0xNC4wNSAxNC4wNSA5LjkgOS45IDE0LjA1LTE0LjA1IDE0LjA1IDE0LjA1IDkuOS05LjktMTQuMDUtMTQuMDV6IiBmaWxsPSIjZmY0MzQzIi8+PC9zdmc+'); --image-build-succeeded: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTE4LjMyIDQ0Ljg5LTE3LjU4LTE3LjU4IDguODUtOC44NCA4LjczIDguNzMgMjIuMDktMjIuMDkgOC44NSA4Ljg0eiIgZmlsbD0iIzk2ZmY0YyIvPjwvc3ZnPg=='); + --image-checkered-flag-cta: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTE3LjIzIDIwLjQ1Yy00LjU3IDEuNTctOS4yOCAyLjQtMTQuMzggMS4wMS0uNjUtMy43LTEuMy03LjM5LTEuOTYtMTEuMDkgNS4xIDEuMzkgOS44LjU2IDE0LjM4LTEuMDEuNjUgMy43IDEuMyA3LjM5IDEuOTYgMTEuMDl6IiBmaWxsPSIjMmYyZjJmIi8+PHBhdGggZD0ibTMwLjgyIDE1LjAxYy00LjU3IDEuNTctOS4wMiAzLjg4LTEzLjU5IDUuNDUtLjY1LTMuNy0xLjMtNy4zOS0xLjk2LTExLjA5IDQuNTctMS41NyA5LjAyLTMuODggMTMuNTktNS40NS42NSAzLjcgMS4zIDcuMzkgMS45NiAxMS4wOXoiIGZpbGw9IiNkYWRhZGEiLz48cGF0aCBkPSJtNDUuMiAxNGMtNS4xLTEuMzktOS44LS41Ni0xNC4zOCAxLjAxLS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA0LjU3LTEuNTcgOS4yOC0yLjQgMTQuMzgtMS4wMS42NSAzLjcgMS4zIDcuMzkgMS45NiAxMS4wOXoiIGZpbGw9IiMyZjJmMmYiLz48cGF0aCBkPSJtMTkuMTggMzEuNTVjLTQuNTcgMS41Ny05LjI4IDIuNC0xNC4zOCAxLjAxLS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA1LjEgMS4zOSA5LjguNTYgMTQuMzgtMS4wMS42NSAzLjcgMS4zIDcuMzkgMS45NiAxMS4wOXoiIGZpbGw9IiNkYWRhZGEiLz48cGF0aCBkPSJtMzIuNzcgMjYuMWMtNC41NyAxLjU3LTkuMDIgMy44OC0xMy41OSA1LjQ1LS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA0LjU3LTEuNTcgOS4wMi0zLjg4IDEzLjU5LTUuNDUuNjUgMy43IDEuMyA3LjM5IDEuOTYgMTEuMDl6IiBmaWxsPSIjMmYyZjJmIi8+PHBhdGggZD0ibTQ3LjE1IDI1LjA5Yy01LjEtMS4zOS05LjgtLjU2LTE0LjM4IDEuMDEtLjY1LTMuNy0xLjMtNy4zOS0xLjk2LTExLjA5IDQuNTctMS41NyA5LjI4LTIuNCAxNC4zOC0xLjAxLjY1IDMuNyAxLjMgNy4zOSAxLjk2IDExLjA5eiIgZmlsbD0iI2RhZGFkYSIvPjxwYXRoIGQ9Im0zNC43MyAzNy4xOWMtNC41NyAxLjU3LTkuMDIgMy44OC0xMy41OSA1LjQ1LS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOSA0LjU3LTEuNTcgOS4wMi0zLjg4IDEzLjU5LTUuNDUuNjUgMy43IDEuMyA3LjM5IDEuOTYgMTEuMDl6IiBmaWxsPSIjZGFkYWRhIi8+PGcgZmlsbD0iIzJmMmYyZiI+PHBhdGggZD0ibTQ5LjExIDM2LjE4Yy01LjEtMS4zOS05LjgtLjU2LTE0LjM4IDEuMDEtLjY1LTMuNy0xLjMtNy4zOS0xLjk2LTExLjA5IDQuNTctMS41NyA5LjI4LTIuNCAxNC4zOC0xLjAxLjY1IDMuNyAxLjMgNy4zOSAxLjk2IDExLjA5eiIvPjxwYXRoIGQ9Im00LjggMzIuNTYgMS41MyA4LjY3LjQzIDIuNDIuNzMgNC4xNSAzLjc2LS42Ni0uNS0yLjgzYzMuNi4yMyA3LjAyLS41MiAxMC4zOC0xLjY3LS42NS0zLjctMS4zLTcuMzktMS45Ni0xMS4wOS00LjU3IDEuNTctOS4yOCAyLjQtMTQuMzggMS4wMXoiLz48L2c+PC9zdmc+'); --image-checkered-flag: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iI2RhZGFkYSI+PHBhdGggZD0ibTE4Ljk1IDE4LjUyYy41IDIuODYgMS4wMSA1LjcyIDEuNTEgOC41OCAzLjU2LTEuMzMgNy4wNC0zLjA5IDEwLjU5LTQuNDEtLjUtMi44Ni0xLjAxLTUuNzItMS41MS04LjU4LTMuNTYgMS4zMy03LjA0IDMuMDktMTAuNTkgNC40MXoiLz48cGF0aCBkPSJtMzguNTYuNzVjLTExLjU4LTEuMTctMjEuMTEgOS4yNi0zMi42OSA4LjA4LTEuMDMtLjEtMi4wNy0uMy0zLjE0LS42MWwuNTIgMi45NCAzLjAzIDE3LjE2IDEuNTEgOC41OC41MiAyLjk0IDEuNjcgOS40OSAyLjk0LS41Mi0xLjQ4LTguMzdjMTEuNTggMS4xOCAyMS4xMS05LjI2IDMyLjY5LTguMDggMS4wMy4xIDIuMDcuMyAzLjE0LjYxbC0uNTItMi45NGMtMS41MS04LjU4LTMuMDMtMTcuMTYtNC41NC0yNS43NGwtLjUyLTIuOTRjLTEuMDYtLjMtMi4xMS0uNS0zLjE0LS42MXptMy41NCAyMC4xYy0zLjg2LS4zOS03LjQ5LjUxLTExLjA1IDEuODNsMS41MSA4LjU4Yy0zLjU2IDEuMzMtNy4wNCAzLjA4LTEwLjYgNC40MWwtMS41MS04LjU4Yy0zLjU2IDEuMzMtNy4xOSAyLjIzLTExLjA1IDEuODNsLS4yLTEuMTJjLS40NC0yLjQ4LS44OC00Ljk3LTEuMzEtNy40NSAzLjg2LjM5IDcuNDktLjUxIDExLjA1LTEuODMtLjQ3LTIuNjctMS4wMy01Ljg0LTEuNTEtOC41OCAzLjU2LTEuMzMgNy4wNC0zLjA4IDEwLjYtNC40MS40OCAyLjczIDEuMDUgNS45NCAxLjUxIDguNTggMy41Ni0xLjMzIDcuMTktMi4yMyAxMS4wNS0xLjgzbDEuNTEgOC41OHoiLz48L2c+PC9zdmc+'); --image-clear-search: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzQtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzQtMjQtMjQtMjR6bTEzLjg4IDMyLjE0LTUuNzQgNS43My04LjE0LTguMTQtOC4xNCA4LjE0LTUuNzMtNS43MyA4LjE0LTguMTQtOC4xNC04LjE0IDUuNzMtNS43NCA4LjE0IDguMTQgOC4xNC04LjE0IDUuNzQgNS43NC04LjE0IDguMTR6IiBmaWxsPSIjMmYyZjJmIi8+PC9zdmc+'); --image-compatibility-unknown: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTE5LjE3IDMxLjF2LTIuNDZjMC0xLjg3LjM4LTMuNDUgMS4xNS00Ljc0Ljc2LTEuMjkgMi4xMi0yLjU0IDQuMDctMy43NiAxLjU1LS45NyAyLjY2LTEuODUgMy4zNC0yLjYzczEuMDItMS42OCAxLjAyLTIuNjljMC0uOC0uMzYtMS40NC0xLjA5LTEuOS0uNzMtLjQ3LTEuNjctLjctMi44My0uNy0yLjg4IDAtNi4yNSAxLjAyLTEwLjExIDMuMDZsLTMuOTgtNy43OWM0Ljc1LTIuNzEgOS43MS00LjA3IDE0Ljg5LTQuMDcgNC4yNiAwIDcuNi45NCAxMC4wMiAyLjgxczMuNjQgNC40MiAzLjY0IDcuNjRjMCAyLjMxLS41NCA0LjMxLTEuNjIgNnMtMi44IDMuMjktNS4xNyA0LjhjLTIgMS4zLTMuMjYgMi4yNS0zLjc2IDIuODUtLjUxLjYtLjc2IDEuMzEtLjc2IDIuMTN2MS40NmgtOC43OXptLTEuMjMgMTAuMDJjMC0xLjc2LjUxLTMuMTEgMS41My00LjA2czIuNTItMS40MyA0LjUxLTEuNDMgMy4zNy40OCA0LjM5IDEuNDUgMS41MyAyLjMxIDEuNTMgNC4wNS0uNTMgMy4wOC0xLjU5IDQuMDRjLTEuMDYuOTUtMi41MSAxLjQzLTQuMzQgMS40M3MtMy4zNy0uNDctNC40NC0xLjQyLTEuNi0yLjI5LTEuNi00LjA1eiIgZmlsbD0iIzNlM2UzZSIvPjwvc3ZnPg=='); diff --git a/FrontEnd/styles/portal.scss b/FrontEnd/styles/portal.scss new file mode 100644 index 000000000..6ea0582e7 --- /dev/null +++ b/FrontEnd/styles/portal.scss @@ -0,0 +1,29 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// ------------------------------------------------------------------------- +// Styles for authentication pages (login, signup, etc.) +// ------------------------------------------------------------------------- + +.portal-form-container { + height: 55vh; + padding: 10%; +} + +.portal-form-inputs { + display: flex; + flex-direction: column; + width: 50%; + margin-bottom: 15px; +} diff --git a/FrontEnd/styles/search.scss b/FrontEnd/styles/search.scss index 9cae50ea1..7f1875858 100644 --- a/FrontEnd/styles/search.scss +++ b/FrontEnd/styles/search.scss @@ -111,7 +111,7 @@ section.search { nav > ul > li.search > form { grid-template-columns: auto 30px; - max-width: 160px; + max-width: 140px; input[type='search'] { padding: 5px; diff --git a/Public/images/portal.svg b/Public/images/portal.svg new file mode 100644 index 000000000..b66181ed6 --- /dev/null +++ b/Public/images/portal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/SVGs/account~dark.svg b/Resources/SVGs/account~dark.svg new file mode 100644 index 000000000..b66181ed6 --- /dev/null +++ b/Resources/SVGs/account~dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/SVGs/account~light.svg b/Resources/SVGs/account~light.svg new file mode 100644 index 000000000..5ea679270 --- /dev/null +++ b/Resources/SVGs/account~light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/App/Controllers/Portal/DeleteAccountController.swift b/Sources/App/Controllers/Portal/DeleteAccountController.swift new file mode 100644 index 000000000..54f179f79 --- /dev/null +++ b/Sources/App/Controllers/Portal/DeleteAccountController.swift @@ -0,0 +1,27 @@ +import Foundation +import Dependencies +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +extension Portal { + + enum DeleteAccountController { + @Sendable + static func deleteAccount(req: Request) async throws -> Response { + @Dependency(\.cognito) var cognito + do { + try await cognito.deleteUser(req: req) + req.auth.logout(AuthenticatedUser.self) + req.session.unauthenticate(AuthenticatedUser.self) + req.session.destroy() + return req.redirect(to: SiteURL.home.relativeURL()) + } catch { + return PortalPage.View(path: SiteURL.portal.relativeURL(), model: PortalPage.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .internalServerError) + } + } + } +} diff --git a/Sources/App/Controllers/Portal/ForgotPasswordController.swift b/Sources/App/Controllers/Portal/ForgotPasswordController.swift new file mode 100644 index 000000000..11b1f32f9 --- /dev/null +++ b/Sources/App/Controllers/Portal/ForgotPasswordController.swift @@ -0,0 +1,32 @@ +import Fluent +import Dependencies +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +extension Portal { + + enum ForgotPasswordController { + @Sendable + static func show(req: Request) async throws -> HTML { + return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model()).document() + } + + @Sendable + static func forgotPasswordEmail(req: Request) async throws -> HTML { + @Dependency(\.cognito) var cognito + struct Credentials: Content { + var email: String + } + do { + let user = try req.content.decode(Credentials.self) + try await cognito.forgotPassword(req: req, username: user.email) + return Reset.View(path: SiteURL.resetPassword.relativeURL(), model: Reset.Model(email: user.email)).document() + } catch { + return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model(errorMessage: "An error occurred: \(error.localizedDescription)")).document() + } + } + } +} diff --git a/Sources/App/Controllers/Portal/LoginController.swift b/Sources/App/Controllers/Portal/LoginController.swift new file mode 100644 index 000000000..cf8275086 --- /dev/null +++ b/Sources/App/Controllers/Portal/LoginController.swift @@ -0,0 +1,56 @@ +import Foundation +import Dependencies +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +enum Portal { + + enum LoginController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Login.View(path: req.url.path, model: Login.Model(errorMessage: "")).document() + } + + @Sendable + static func login(req: Request) async throws -> Response { + @Dependency(\.cognito) var cognito + struct UserCreds: Content { + var email: String + var password: String + } + do { + let user = try req.content.decode(UserCreds.self) + let response = try await cognito.authenticate(req: req, username: user.email, password: user.password) + switch response { + case .authenticated(let authenticatedResponse): + let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!) + req.auth.login(user) + case .challenged(_): // Cognito is not configured to send challenges, so we should never receive this response. + break + } + return req.redirect(to: SiteURL.portal.relativeURL(), redirectType: .normal) + } catch let error as SotoCognitoError { + var model = Login.Model(errorMessage: "There was an error. Please try again.") + switch error { + case .unauthorized(let reason): + model = Login.Model(errorMessage: reason ?? "There was an error. Please try again.") + case .unexpectedResult(let reason): + model = Login.Model(errorMessage: reason ?? "There was an error. Please try again.") + case .invalidPublicKey: + break + } + return Login.View(path: req.url.path, model: model).document().encodeResponse(status: .unauthorized) + } catch let error as AWSClientError { + return Login.View(path: SiteURL.login.relativeURL(), model: Login.Model(errorMessage: "An AWS client error occurred: \(error.errorCode)")).document().encodeResponse(status: .unauthorized) + } catch { + return Login.View(path: SiteURL.login.relativeURL(), model: Login.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .unauthorized) + } + + } + } +} + diff --git a/Sources/App/Controllers/Portal/LogoutController.swift b/Sources/App/Controllers/Portal/LogoutController.swift new file mode 100644 index 000000000..10ddd56fe --- /dev/null +++ b/Sources/App/Controllers/Portal/LogoutController.swift @@ -0,0 +1,21 @@ +import Foundation +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +extension Portal { + + enum LogoutController { + @Sendable + static func logout(req: Request) async throws -> Response { + req.auth.logout(AuthenticatedUser.self) + req.session.unauthenticate(AuthenticatedUser.self) + req.session.destroy() + return req.redirect(to: SiteURL.home.relativeURL()) + } + } +} + diff --git a/Sources/App/Controllers/Portal/PortalController.swift b/Sources/App/Controllers/Portal/PortalController.swift new file mode 100644 index 000000000..109f1db99 --- /dev/null +++ b/Sources/App/Controllers/Portal/PortalController.swift @@ -0,0 +1,14 @@ +import Fluent +import Plot +import Vapor +import SotoCognitoAuthenticationKit + +extension Portal { + + enum PortalController { + @Sendable + static func show(req: Request) async throws -> HTML { + return PortalPage.View(path: req.url.path, model: PortalPage.Model()).document() + } + } +} diff --git a/Sources/App/Controllers/Portal/ResetController.swift b/Sources/App/Controllers/Portal/ResetController.swift new file mode 100644 index 000000000..d44912da8 --- /dev/null +++ b/Sources/App/Controllers/Portal/ResetController.swift @@ -0,0 +1,40 @@ +import Fluent +import Dependencies +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +extension Portal { + + enum ResetController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Reset.View(path: req.url.path, model: Reset.Model()).document() + } + + @Sendable + static func resetPassword(req: Request) async throws -> HTML { + @Dependency(\.cognito) var cognito + struct UserInfo: Content { + var email: String + var password: String + var confirmationCode: String + } + do { + let user = try req.content.decode(UserInfo.self) + try await cognito.resetPassword(req: req, username: user.email, password: user.password, confirmationCode: user.confirmationCode) + let model = SuccessfulChange.Model(successMessage: "Successfully changed password") + return SuccessfulChange.View(path: req.url.path, model: model).document() + } catch let error as AWSErrorType { + let errorMessage = (error.message != nil) ? "There was an error: \(error.message)" : "There was an error: \(error.localizedDescription)" + let model = Reset.Model(errorMessage: errorMessage) + return Reset.View(path: req.url.path, model: model).document() + } catch { + let model = Reset.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)") + return Reset.View(path: req.url.path, model: model).document() + } + } + } +} diff --git a/Sources/App/Controllers/Portal/SignupController.swift b/Sources/App/Controllers/Portal/SignupController.swift new file mode 100644 index 000000000..4c4a4d390 --- /dev/null +++ b/Sources/App/Controllers/Portal/SignupController.swift @@ -0,0 +1,38 @@ +import Fluent +import Dependencies +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +extension Portal { + + enum SignupController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Signup.View(path: req.url.path, model: Signup.Model(errorMessage: "")).document() + } + + @Sendable + static func signup(req: Request) async throws -> HTML { + @Dependency(\.cognito) var cognito + struct UserCreds: Content { + var email: String + var password: String + } + do { + let user = try req.content.decode(UserCreds.self) + try await cognito.signup(req: req, username: user.email, password: user.password) + return Verify.View(path: SiteURL.verify.relativeURL(), model: Verify.Model(email: user.email)).document() + } catch let error as AWSErrorType { + let errorMessage = (error.message != nil) ? "There was an error: \(error.message)" : "There was an error: \(error.localizedDescription)" + let model = Signup.Model(errorMessage: errorMessage) + return Signup.View(path: req.url.path, model: model).document() + } catch { + return Signup.View(path: SiteURL.signup.relativeURL(), model: Signup.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document() + } + + } + } +} diff --git a/Sources/App/Controllers/Portal/VerifyController.swift b/Sources/App/Controllers/Portal/VerifyController.swift new file mode 100644 index 000000000..ea55ee0d0 --- /dev/null +++ b/Sources/App/Controllers/Portal/VerifyController.swift @@ -0,0 +1,42 @@ + +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity +import Dependencies + +extension Portal { + + enum VerifyController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Verify.View(path: req.url.path, model: Verify.Model(email: "")).document() + } + + @Sendable + static func verify(req: Request) async throws -> HTML { + @Dependency(\.cognito) var cognito + struct VerifyInformation: Content { + var email: String + var confirmationCode: String + } + do { + let info = try req.content.decode(VerifyInformation.self) + try await cognito.confirmSignUp(req: req, username: info.email, confirmationCode: info.confirmationCode) + let model = SuccessfulChange.Model(successMessage: "Successfully confirmed signup") + return SuccessfulChange.View(path: req.url.path, model: model).document() + } catch let error as AWSErrorType { + let info = try req.content.decode(VerifyInformation.self) + let errorMessage = (error.message != nil) ? "There was an error: \(error.message)" : "There was an error: \(error.localizedDescription)" + let model = Verify.Model(email: info.email, errorMessage: errorMessage) + return Verify.View(path: req.url.path, model: model).document() + } catch { + let info = try req.content.decode(VerifyInformation.self) + let model = Verify.Model(email: info.email, errorMessage: "An unknown error occurred: \(error.localizedDescription)") + return Verify.View(path: req.url.path, model: model).document() + } + } + } +} diff --git a/Sources/App/Core/Cognito.swift b/Sources/App/Core/Cognito.swift new file mode 100644 index 000000000..4a24b47aa --- /dev/null +++ b/Sources/App/Core/Cognito.swift @@ -0,0 +1,149 @@ +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + + +struct Cognito { + @Sendable + static func authenticate(req: Request, username: String, password: String) async throws -> CognitoAuthenticateResponse { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + let response = try await req.application.cognito.authenticatable.authenticate(username: username, password: password) + try await awsClient.shutdown() + return response + } catch { + try await awsClient.shutdown() + throw error + } + } + + @Sendable + static func authenticateToken(req: Request, sessionID: String, accessToken: String) async throws -> Void { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + let _ = try await req.application.cognito.authenticatable.authenticate(accessToken: sessionID, on: req.eventLoop) + try await awsClient.shutdown() + } catch { + try await awsClient.shutdown() + throw error + } + } + + @Sendable + static func signup(req: Request, username: String, password: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + _ = try await req.application.cognito.authenticatable.signUp(username: username, password: password, attributes: [:], on:req.eventLoop) + try await awsClient.shutdown() + } catch { + try await awsClient.shutdown() + throw error + } + } + + @Sendable + static func forgotPassword(req: Request, username: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + try await req.application.cognito.authenticatable.forgotPassword(username: username) + try await awsClient.shutdown() + } catch { + try await awsClient.shutdown() + throw error + } + } + + @Sendable + static func resetPassword(req: Request, username: String, password: String, confirmationCode: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + try await req.application.cognito.authenticatable.confirmForgotPassword(username: username, newPassword: password, confirmationCode: confirmationCode) + try await awsClient.shutdown() + } catch { + try await awsClient.shutdown() + throw error + } + } + + @Sendable + static func confirmSignUp(req: Request, username: String, confirmationCode: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + try await req.application.cognito.authenticatable.confirmSignUp(username: username, confirmationCode: confirmationCode) + try await awsClient.shutdown() + } catch { + try await awsClient.shutdown() + throw error + } + } + + @Sendable + static func deleteUser(req: Request) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + do { + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, + clientId: Environment.get("AWS_COGNITO_CLIENT_ID")!, + clientSecret: Environment.get("AWS_COGNITO_CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + let request = try CognitoIdentityProvider.DeleteUserRequest(accessToken: req.auth.require(AuthenticatedUser.self).sessionID) + try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) + try await awsClient.shutdown() + } catch { + try await awsClient.shutdown() + throw error + } + } +} diff --git a/Sources/App/Core/Dependencies/CognitoClient.swift b/Sources/App/Core/Dependencies/CognitoClient.swift new file mode 100644 index 000000000..17cff351a --- /dev/null +++ b/Sources/App/Core/Dependencies/CognitoClient.swift @@ -0,0 +1,55 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Dependencies +import DependenciesMacros +import Vapor +import SotoCognitoAuthenticationKit + +@DependencyClient +struct CognitoClient { + var authenticate: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse + var authenticateToken: @Sendable (_ req: Request, _ sessionID: String, _ accessToken: String) async throws -> Void + var signup: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void + var resetPassword: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void + var forgotPassword: @Sendable (_ req: Request, _ username: String) async throws -> Void + var confirmSignUp: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void + var deleteUser: @Sendable (_ req: Request) async throws -> Void +} + +extension CognitoClient: DependencyKey { + static var liveValue: CognitoClient { + .init( + authenticate: { req, username, password in try await Cognito.authenticate(req: req, username: username, password: password) }, + authenticateToken: { req, sessionID, accessToken in try await Cognito.authenticateToken(req: req, sessionID: sessionID, accessToken: accessToken)}, + signup : { req, username, password in try await Cognito.signup(req: req, username: username, password: password) }, + resetPassword : { req, username, password, confirmationCode in try await Cognito.resetPassword(req: req, username: username, password: password, confirmationCode: confirmationCode) }, + forgotPassword: { req, username in try await Cognito.forgotPassword(req: req, username: username) }, + confirmSignUp: { req, username, confirmationCode in try await Cognito.confirmSignUp(req: req, username: username, confirmationCode: confirmationCode) }, + deleteUser: { req in try await Cognito.deleteUser(req: req) } + ) + } +} + +extension CognitoClient: Sendable, TestDependencyKey { + static var testValue: Self { Self() } +} + +extension DependencyValues { + var cognito: CognitoClient { + get { self[CognitoClient.self] } + set { self[CognitoClient.self] = newValue } + } +} + diff --git a/Sources/App/Core/SessionAuthentication.swift b/Sources/App/Core/SessionAuthentication.swift new file mode 100644 index 000000000..32a67af4e --- /dev/null +++ b/Sources/App/Core/SessionAuthentication.swift @@ -0,0 +1,28 @@ +import Vapor +import Dependencies +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +struct AuthenticatedUser { + var accessToken: String +} + +extension AuthenticatedUser: SessionAuthenticatable { + var sessionID: String { + self.accessToken + } +} + +struct UserSessionAuthenticator: AsyncSessionAuthenticator { + func authenticate(sessionID: String, for request: Vapor.Request) async throws { + @Dependency(\.cognito) var cognito + do { + try await cognito.authenticateToken(req: request, sessionID: sessionID, accessToken: sessionID) + request.auth.login(User(accessToken: sessionID)) + } catch _ as SotoCognitoError { + // TODO: .unauthorized SotoCognitoError with reason "invalid token", attempt to refresh using req.application.cognito.authenticatable.refresh(), which requires the username and refresh token, both returned upon initial successful login. + } + } + typealias User = AuthenticatedUser +} diff --git a/Sources/App/Core/SiteURL.swift b/Sources/App/Core/SiteURL.swift index 5b9fad00c..e6a4860c0 100644 --- a/Sources/App/Core/SiteURL.swift +++ b/Sources/App/Core/SiteURL.swift @@ -115,22 +115,29 @@ enum SiteURL: Resourceable, Sendable { case buildMonitor case builds(_ id: Parameter) case collections(_ key: Parameter) + case deleteAccount case docs(Docs) case faq + case forgotPassword case home case images(String) case javascripts(String) case keywords(_ keyword: Parameter) + case login + case logout case package(_ owner: Parameter, _ repository: Parameter, PackagePathComponents?) case packageCollectionKeyword(_ keyword: Parameter) case packageCollectionAuthor(_ owner: Parameter) case packageCollectionCustom(_ key: Parameter) case packageCollections + case portal case privacy case readyForSwift6 + case resetPassword case rssPackages case rssReleases case search + case signup case siteMapIndex case siteMapStaticPages case stylesheets(String) @@ -138,6 +145,7 @@ enum SiteURL: Resourceable, Sendable { case tryInPlayground case healthCheck case validateSPIManifest + case verify var path: String { switch self { @@ -173,6 +181,9 @@ enum SiteURL: Resourceable, Sendable { case .buildMonitor: return "build-monitor" + + case .deleteAccount: + return "delete" case let .collections(.value(key)): return "collections/\(key.urlPathEncoded)" @@ -185,6 +196,9 @@ enum SiteURL: Resourceable, Sendable { case .faq: return "faq" + + case .forgotPassword: + return "forgot-password" case .home: return "" @@ -200,6 +214,12 @@ enum SiteURL: Resourceable, Sendable { case .keywords: fatalError("invalid path: \(self)") + + case .login: + return "login" + + case .logout: + return "logout" case let .package(.value(owner), .value(repo), .none): let owner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner @@ -232,12 +252,18 @@ enum SiteURL: Resourceable, Sendable { case .packageCollections: return "package-collections" + + case .portal: + return "portal" case .privacy: return "privacy" case .readyForSwift6: return "ready-for-swift-6" + + case .resetPassword: + return "reset-password" case .rssPackages: return "packages.rss" @@ -247,6 +273,9 @@ enum SiteURL: Resourceable, Sendable { case .search: return "search" + + case .signup: + return "signup" case .siteMapIndex: return "sitemap.xml" @@ -268,6 +297,9 @@ enum SiteURL: Resourceable, Sendable { case .validateSPIManifest: return "validate-spi-manifest" + + case .verify: + return "verify" } } @@ -276,20 +308,28 @@ enum SiteURL: Resourceable, Sendable { case .addAPackage, .blog, .buildMonitor, + .deleteAccount, .faq, + .forgotPassword, .home, + .login, + .logout, .packageCollections, + .portal, .privacy, .readyForSwift6, + .resetPassword, .rssPackages, .rssReleases, .search, + .signup, .siteMapIndex, .siteMapStaticPages, .supporters, .tryInPlayground, - .healthCheck, - .validateSPIManifest: + .validateSPIManifest, + .verify, + .healthCheck: return [.init(stringLiteral: path)] case let .api(next): diff --git a/Sources/App/Core/Supporters+GitHub.swift b/Sources/App/Core/Supporters+GitHub.swift index b09be922a..213bb7ddd 100644 --- a/Sources/App/Core/Supporters+GitHub.swift +++ b/Sources/App/Core/Supporters+GitHub.swift @@ -412,5 +412,10 @@ extension Array { name: "Sparrow Code", avatarUrl: "https://avatars.githubusercontent.com/u/98487302?v=4" ), + .init( + login: "coderabbitai", + name: "CodeRabbit", + avatarUrl: "https://avatars.githubusercontent.com/u/132028505?v=4" + ), ] } diff --git a/Sources/App/Views/Blog/BlogActions+Index+View.swift b/Sources/App/Views/Blog/BlogActions+Index+View.swift index 763a33f8f..0a74b2a3e 100644 --- a/Sources/App/Views/Blog/BlogActions+Index+View.swift +++ b/Sources/App/Views/Blog/BlogActions+Index+View.swift @@ -14,6 +14,7 @@ import Foundation import Plot +import Dependencies extension BlogActions { @@ -108,7 +109,12 @@ extension BlogActions { } override func navMenuItems() -> [NavMenuItem] { - [.supporters, .searchLink, .addPackage, .faq] + @Dependency(\.environment) var environment + if environment.current() == .production { + return [.supporters, .searchLink, .addPackage, .faq] + } else { + return [.supporters, .searchLink, .addPackage, .faq, .portal] + } } } diff --git a/Sources/App/Views/Home/HomeIndex+View.swift b/Sources/App/Views/Home/HomeIndex+View.swift index f9c73cdfd..5082b94d0 100644 --- a/Sources/App/Views/Home/HomeIndex+View.swift +++ b/Sources/App/Views/Home/HomeIndex+View.swift @@ -13,6 +13,7 @@ // limitations under the License. import Plot +import Dependencies enum HomeIndex { @@ -125,7 +126,12 @@ enum HomeIndex { } override func navMenuItems() -> [NavMenuItem] { - [.supporters, .addPackage, .blog, .faq] + @Dependency(\.environment) var environment + if environment.current() == .production { + return [.supporters, .addPackage, .blog, .faq] + } else { + return [.supporters, .addPackage, .blog, .faq, .portal] + } } } } diff --git a/Sources/App/Views/NavMenuItems.swift b/Sources/App/Views/NavMenuItems.swift index 182fe094f..62fa37ad9 100644 --- a/Sources/App/Views/NavMenuItems.swift +++ b/Sources/App/Views/NavMenuItems.swift @@ -21,6 +21,7 @@ enum NavMenuItem { case faq case search case searchLink + case portal func listNode() -> Node { switch self { @@ -36,7 +37,7 @@ enum NavMenuItem { return .li( .a( .href(SiteURL.addAPackage.relativeURL()), - "Add a Package" + "Add Package" ) ) case .blog: @@ -65,6 +66,18 @@ enum NavMenuItem { "Search Packages" ) ) + case .portal: + return .li( + .class("portal"), + .a( + .href(SiteURL.portal.relativeURL()), + .img( + .alt("Portal"), + .src(SiteURL.images("portal.svg").relativeURL()), + .width(20) + ) + ) + ) } } } diff --git a/Sources/App/Views/Plot+Extensions.swift b/Sources/App/Views/Plot+Extensions.swift index f4260b34d..293a849f1 100644 --- a/Sources/App/Views/Plot+Extensions.swift +++ b/Sources/App/Views/Plot+Extensions.swift @@ -249,6 +249,43 @@ extension Node where Context == HTML.FormContext { .value(query) ) } + + static func emailField(email: String = "") -> Self { + .input( + .id("email"), + .name("email"), + .type(.email), + .placeholder("Enter email"), + .spellcheck(false), + .autocomplete(false), + .value(email) + ) + } + + static func passwordField(password: String = "", passwordFieldText: String = "Enter password") -> Self { + .input( + .id("password"), + .name("password"), + .type(.password), + .placeholder(passwordFieldText), + .spellcheck(false), + .autocomplete(false), + .value(password) + ) + } + + static func confirmationCodeField(code: String = "") -> Self { + .input( + .class("portal-form-inputs"), + .id("confirmationCode"), + .name("confirmationCode"), + .type(.text), + .placeholder("Confirmation code"), + .spellcheck(false), + .autocomplete(false), + .value(code) + ) + } } extension Node where Context == HTML.ListContext { diff --git a/Sources/App/Views/Portal/ForgotPassword+View.swift b/Sources/App/Views/Portal/ForgotPassword+View.swift new file mode 100644 index 000000000..0fe048a5f --- /dev/null +++ b/Sources/App/Views/Portal/ForgotPassword+View.swift @@ -0,0 +1,46 @@ +import Plot +import Foundation + +enum ForgotPassword { + + struct Model { + var errorMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Forgot Password" + } + + override func content() -> Node { + .div( + .class("portal-form-container"), + .h2("An email will be sent with a reset code"), + .forgotPasswordForm(), + .text(model.errorMessage) + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func forgotPasswordForm(email: String = "") -> Self { + .form( + .action(SiteURL.forgotPassword.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .emailField(email: email) , + .button( + .type(.submit) + ) + ) + } +} diff --git a/Sources/App/Views/Portal/Login+View.swift b/Sources/App/Views/Portal/Login+View.swift new file mode 100644 index 000000000..2abc96796 --- /dev/null +++ b/Sources/App/Views/Portal/Login+View.swift @@ -0,0 +1,76 @@ +import Plot +import Foundation + +enum Login { + + struct Model { + var errorMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Log in" + } + + override func content() -> Node { + .div( + .h2("Login to Swift Package Index"), + .loginForm(), + .if(model.errorMessage.isEmpty == false, + .p( + .text(model.errorMessage) + ) + ), + .signupButton("Create an account"), + .forgotPassword("Reset your password") + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func loginForm(email: String = "", password: String = "") -> Self { + .form( + .action(SiteURL.login.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .emailField(email: email) , + .passwordField(password: password), + .button( + .text("Login"), + .type(.submit) + ) + ) + } + + static func signupButton(_ text: String) -> Self { + .form( + .class("signup"), + .action(SiteURL.signup.relativeURL()), + .button( + .text(text), + .type(.submit) + ) + ) + } + + static func forgotPassword(_ text: String) -> Self { + .form( + .class("forgot"), + .action(SiteURL.forgotPassword.relativeURL()), + .button( + .text(text), + .type(.submit) + ) + ) + } +} + diff --git a/Sources/App/Views/Portal/Portal+View.swift b/Sources/App/Views/Portal/Portal+View.swift new file mode 100644 index 000000000..7a4f3ac9a --- /dev/null +++ b/Sources/App/Views/Portal/Portal+View.swift @@ -0,0 +1,61 @@ +import Plot +import Foundation + +enum PortalPage { + + struct Model { + var errorMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Portal" + } + + override func content() -> Node { + .div( + .class("portal-form-container"), + .h2("Portal"), + .logoutButton(), + .deleteButton(), + .text(model.errorMessage) + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func logoutButton() -> Self { + .form( + .class("portal-form-inputs"), + .action(SiteURL.logout.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .button( + .type(.submit), + .text("logout") + ) + ) + } + + static func deleteButton() -> Self { + .form( + .class("portal-form-inputs"), + .action(SiteURL.deleteAccount.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .button( + .type(.submit), + .text("delete account") + ) + ) + } +} diff --git a/Sources/App/Views/Portal/Reset+View.swift b/Sources/App/Views/Portal/Reset+View.swift new file mode 100644 index 000000000..e3f32c07a --- /dev/null +++ b/Sources/App/Views/Portal/Reset+View.swift @@ -0,0 +1,50 @@ +import Plot +import Foundation + +enum Reset { + + struct Model { + var email: String = "" + var errorMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Reset Password" + } + + override func content() -> Node { + .div( + .h2("Reset Password"), + .resetPasswordForm(), + .text(model.errorMessage) + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func resetPasswordForm(email: String = "", password: String = "", code: String = "") -> Self { + .form( + .action(SiteURL.resetPassword.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .confirmationCodeField(code: code), + .emailField(email: email) , + .passwordField(password: password, passwordFieldText: "Enter new password"), + .button( + .text("Send reset code"), + .type(.submit) + ) + ) + } +} + diff --git a/Sources/App/Views/Portal/Signup+View.swift b/Sources/App/Views/Portal/Signup+View.swift new file mode 100644 index 000000000..ddb4acaab --- /dev/null +++ b/Sources/App/Views/Portal/Signup+View.swift @@ -0,0 +1,48 @@ +import Plot +import Foundation + +enum Signup { + + struct Model { + var errorMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Sign up" + } + + override func content() -> Node { + .div( + .class("portal-form-container"), + .h2("Signup"), + .signupForm(), + .text(model.errorMessage) + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func signupForm(email: String = "", password: String = "") -> Self { + .form( + .action(SiteURL.signup.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .emailField(email: email), + .passwordField(password: password), + .button( + .text("Sign up"), + .type(.submit) + ) + ) + } +} diff --git a/Sources/App/Views/Portal/Successful+Password+Change.swift b/Sources/App/Views/Portal/Successful+Password+Change.swift new file mode 100644 index 000000000..8a244489a --- /dev/null +++ b/Sources/App/Views/Portal/Successful+Password+Change.swift @@ -0,0 +1,43 @@ +import Plot +import Foundation + +enum SuccessfulChange { + + struct Model { + var successMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Success" + } + + override func content() -> Node { + .div( + .class("portal-form-container"), + .text(self.model.successMessage), + .loginRedirectButton() + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func loginRedirectButton() -> Self { + .form( + .action(SiteURL.login.relativeURL()), + .button( + .text("Login"), + .type(.submit) + ) + ) + } +} diff --git a/Sources/App/Views/Portal/Verify+View.swift b/Sources/App/Views/Portal/Verify+View.swift new file mode 100644 index 000000000..3cd39c62c --- /dev/null +++ b/Sources/App/Views/Portal/Verify+View.swift @@ -0,0 +1,58 @@ +import Plot +import Foundation + +enum Verify { + + struct Model { + var email: String + var errorMessage: String = "" + } + + class View: PublicPage { + + let model: Model + + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } + + override func pageTitle() -> String? { + "Verify" + } + + override func content() -> Node { + .div( + .class("portal-form-container"), + .p( + .text("Please enter the confirmation code sent to your email") + ), + .verifyForm(email: model.email), + .p( + .text(model.errorMessage) + ) + ) + } + } +} + +extension Node where Context: HTML.BodyContext { + static func verifyForm(email: String = "", code: String = "") -> Self { + .form( + .action(SiteURL.verify.relativeURL()), + .method(.post), + .input( + .id("email"), + .name("email"), + .type(.hidden), + .value(email) + ), + .confirmationCodeField(code: code), + .data(named: "turbo", value: "false"), + .button( + .text("Confirm sign up"), + .type(.submit) + ) + ) + } +} diff --git a/Sources/App/Views/PublicPage.swift b/Sources/App/Views/PublicPage.swift index 216f3701f..fdb130b4d 100644 --- a/Sources/App/Views/PublicPage.swift +++ b/Sources/App/Views/PublicPage.swift @@ -323,7 +323,12 @@ class PublicPage { /// The items to be rendered in the site navigation menu. /// - Returns: An array of `NavMenuItem` items used in `header`. func navMenuItems() -> [NavMenuItem] { - [.supporters, .addPackage, .blog, .faq, .search] + @Dependency(\.environment) var environment + if environment.current() == .production { + return [.supporters, .addPackage, .blog, .faq, .search] + } else { + return [.supporters, .addPackage, .blog, .faq, .search, .portal] + } } func announcementBanner() -> Node { diff --git a/Sources/App/Views/Search/SearchShow+View.swift b/Sources/App/Views/Search/SearchShow+View.swift index 5d06cce3a..f7d60034f 100644 --- a/Sources/App/Views/Search/SearchShow+View.swift +++ b/Sources/App/Views/Search/SearchShow+View.swift @@ -13,6 +13,7 @@ // limitations under the License. import Plot +import Dependencies extension SearchShow { @@ -64,7 +65,12 @@ extension SearchShow { } override func navMenuItems() -> [NavMenuItem] { - [.supporters, .addPackage, .blog, .faq] + @Dependency(\.environment) var environment + if environment.current() == .production { + return [.supporters, .addPackage, .blog, .faq] + } else { + return [.supporters, .addPackage, .blog, .faq, .portal] + } } func resultsSection() -> Node { diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 2d442bb8d..fc1aa26d0 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -16,6 +16,9 @@ import Dependencies import Fluent import FluentPostgresDriver import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity @discardableResult @@ -76,7 +79,15 @@ public func configure(_ app: Application, databasePort: Int? = nil) async throws // Set sqlLogLevel to .info to log SQL queries with the default log level. sqlLogLevel: .debug), as: .psql) - + + app.sessions.use(.memory) + + + // Configures cookie value creation. + app.sessions.configuration.cookieFactory = { sessionID in + .init(string: sessionID.string, isSecure: true, isHTTPOnly: true) + } + do { // Migration 001 - schema 1.0 app.migrations.add(CreatePackage()) app.migrations.add(CreateRepository()) diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index f21c9e372..496d2a0d1 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -137,6 +137,48 @@ func routes(_ app: Application) throws { app.get(SiteURL.validateSPIManifest.pathComponents, use: ValidateSPIManifestController.show) app.post(SiteURL.validateSPIManifest.pathComponents, use: ValidateSPIManifestController.validate) } + + let auth = app.routes.grouped([app.sessions.middleware, UserSessionAuthenticator()]) + let redirect = auth.grouped(AuthenticatedUser.redirectMiddleware(path: SiteURL.login.relativeURL())) + + if environment.current() != .production { + do { + redirect.get(SiteURL.portal.pathComponents, use: Portal.PortalController.show) + } + + do { + auth.get(SiteURL.login.pathComponents, use: Portal.LoginController.show) + auth.post(SiteURL.login.pathComponents, use: Portal.LoginController.login) + } + + do { + auth.get(SiteURL.signup.pathComponents, use: Portal.SignupController.show) + auth.post(SiteURL.signup.pathComponents, use: Portal.SignupController.signup) + } + + do { + auth.get(SiteURL.verify.pathComponents, use: Portal.VerifyController.show) + auth.post(SiteURL.verify.pathComponents, use: Portal.VerifyController.verify) + } + + do { + auth.post(SiteURL.logout.pathComponents, use: Portal.LogoutController.logout) + } + + do { + auth.post(SiteURL.deleteAccount.pathComponents, use: Portal.DeleteAccountController.deleteAccount) + } + + do { + app.get(SiteURL.forgotPassword.pathComponents, use: Portal.ForgotPasswordController.show) + app.post(SiteURL.forgotPassword.pathComponents, use: Portal.ForgotPasswordController.forgotPasswordEmail) + } + + do { + app.get(SiteURL.resetPassword.pathComponents, use: Portal.ResetController.show) + app.post(SiteURL.resetPassword.pathComponents, use: Portal.ResetController.resetPassword) + } + } // Ready for Swift 6 app.get(SiteURL.readyForSwift6.pathComponents, use: ReadyForSwift6Controller.show) diff --git a/Tests/AppTests/AllTests.swift b/Tests/AppTests/AllTests.swift index a7f4c67d0..f4fa5eb0e 100644 --- a/Tests/AppTests/AllTests.swift +++ b/Tests/AppTests/AllTests.swift @@ -92,6 +92,7 @@ extension AllTests { @Suite struct PackageTests { } @Suite struct PipelineTests { } @Suite struct PlausibleTests { } + @Suite struct PortalTests {} @Suite struct ProductTests { } @Suite struct QueryPlanTests { } @Suite struct RSSTests { } diff --git a/Tests/AppTests/PortalTests.swift b/Tests/AppTests/PortalTests.swift new file mode 100644 index 000000000..0a538a726 --- /dev/null +++ b/Tests/AppTests/PortalTests.swift @@ -0,0 +1,414 @@ +// Copyright Dave Verwer, Sven A. Schmidt, and other contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import App + +import Testing + +import Fluent +import Vapor +import Dependencies +import SotoCognitoAuthenticationKit + +extension AllTests.PortalTests { + + @Test func test_portal_route_protected() async throws { + try await withApp { app in + try await app.test(.GET, "portal", afterResponse: { res async throws in + #expect(res.status == .seeOther) + if let location = res.headers.first(name: .location) { + #expect("/login" == location) + } + }) + } + } + + @Test func test_login_successful_redirect() async throws { + try await withDependencies { + let jsonData: Data = """ + { + "authenticated": { + "accessToken": "", + "idToken": "", + "refreshToken": "", + } + } + """.data(using: .utf8)! + let decoder = JSONDecoder() + let response = try decoder.decode(CognitoAuthenticateResponse.self, from: jsonData) + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in return response } + $0.cognito.authenticate = mock + } operation: { + try await withApp { app in + try await app.test(.POST, "login", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + #expect(res.status == .seeOther) + if let location = res.headers.first(name: .location) { + #expect("/portal" == location) + } + }) + } + } + } + + @Test func test_successful_login_secure_cookie_set() async throws { + try await withDependencies { + let jsonData: Data = """ + { + "authenticated": { + "accessToken": "123", + "idToken": "", + "refreshToken": "", + } + } + """.data(using: .utf8)! + let decoder = JSONDecoder() + let response = try decoder.decode(CognitoAuthenticateResponse.self, from: jsonData) + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in + return response + } + $0.cognito.authenticate = mock + } operation: { + try await withApp { app in + try await app.test(.POST, "login", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + if let cookieHeader = res.headers.first(name: .setCookie) { + #expect(cookieHeader.contains("HttpOnly") == true) + #expect(cookieHeader.contains("Secure") == true) + } + }) + } + } + } + + @Test func test_login_soto_error() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in + throw SotoCognitoError.unauthorized(reason: "reason") + } + $0.cognito.authenticate = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "login", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + #expect(res.status == .unauthorized) + }) + } + } + } + + @Test func test_login_some_aws_client_error() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in + throw AWSClientError.accessDenied + } + $0.cognito.authenticate = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "login", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + #expect(res.status == .unauthorized) + }) + } + } + } + + @Test func test_login_throw_other_error() async throws { + struct SomeError: Error {} + + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in + throw SomeError() + } + $0.cognito.authenticate = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "login", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + #expect(res.status == .unauthorized) + }) + } + } + } + + @Test func test_signup_successful_view_change() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in } + $0.cognito.signup = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "signup", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + #expect(res.status == .ok) + #expect(res.body.string.contains("Verify") == true) + }) + } + } + } + + @Test func test_signup_some_aws_error() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in + throw AWSClientError.accessDenied + } + $0.cognito.signup = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "signup", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + #expect(res.body.string.contains("There was an error") == true) + }) + } + } + } + + @Test func test_signup_throw_some_error() async throws { + struct SomeError: Error {} + + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in + throw SomeError() + } + $0.cognito.signup = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "signup") { res async throws in + #expect(res.body.string.contains("error") == true) + } + } + } + } + + @Test func test_reset_password_successful_view_change() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void = { _, _, _, _ in } + $0.cognito.resetPassword = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "reset-password", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + }, afterResponse: { res in + #expect(res.status == .ok) + #expect(res.body.string.contains("Successfully changed password") == true) + }) + } + } + } + + @Test func test_reset_pass_throws_aws_error() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void = { _, _, _, _ in + throw AWSClientError.accessDenied + } + $0.cognito.resetPassword = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "reset-password", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + }, afterResponse: { res in + #expect(res.body.string.contains("There was an error") == true) + }) + } + } + } + + @Test func test_reset_pass_throws_other_error() async throws { + try await withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void = { _, _, _, _ in + throw SomeError() + } + $0.cognito.resetPassword = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "reset-password", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + }, afterResponse: { res in + #expect(res.body.string.contains("An unknown error occurred")) + }) + } + } + } + + @Test func test_forgot_pass_successful_view_change() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String) async throws -> Void = { _, _ in } + $0.cognito.forgotPassword = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "forgot-password", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail"]) + }, afterResponse: { res in + #expect(res.status == .ok) + #expect(res.body.string.contains("Reset Password") == true) + }) + } + } + } + + @Test func test_forgot_pass_throws() async throws { + try await withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request, _ username: String) async throws -> Void = { _, _ in throw SomeError() } + $0.cognito.forgotPassword = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "forgot-password", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail"]) + }, afterResponse: { res in + #expect(res.status == .ok) + #expect(res.body.string.contains("An error occurred") == true) + }) + } + } + } + + @Test func test_logout_successful_redirect() async throws { + try await withApp { app in + try await app.test(.POST, "logout") { res async throws in + #expect(res.status == .seeOther) + if let location = res.headers.first(name: .location) { + #expect("/" == location) + } + } + } + } + + @Test func test_logout_session_destroyed() async throws { + try await withApp { app in + try await app.test(.POST, "logout") { res async throws in + let cookie = res.headers.setCookie?["vapor-session"] + #expect(cookie == nil) + } + } + } + + @Test func test_verify_successful_view_Change() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in } + $0.cognito.confirmSignUp = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "verify", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "confirmationCode": "123"]) + }, afterResponse: { res in + #expect(res.status == .ok) + #expect(res.body.string.contains("Successfully confirmed signup") == true) + }) + } + } + } + + @Test func test_verify_throws_aws_error() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in + throw AWSClientError.accessDenied + } + $0.cognito.confirmSignUp = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "verify", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "confirmationCode": "123"]) + }, afterResponse: { res in + #expect(res.body.string.contains("There was an error") == true) + }) + } + } + } + + @Test func test_verify_throws_some_error() async throws { + try await withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in + throw SomeError() + } + $0.cognito.confirmSignUp = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "verify", beforeRequest: { req async throws in + try req.content.encode(["email": "testemail", "confirmationCode": "123"]) + }, afterResponse: { res in + #expect(res.body.string.contains("An unknown error occurred")) + }) + } + } + } + + @Test func test_delete_successful_redirect() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request) async throws -> Void = { _ in } + $0.cognito.deleteUser = mock + } operation: { + try await withApp { app in + try await app.test(.POST, "delete") { res async throws in + #expect(res.status == .seeOther) + if let location = res.headers.first(name: .location) { + #expect("/" == location) + } + } + } + } + } + + @Test func test_delete_session_destroyed() async throws { + try await withDependencies { + let mock: @Sendable (_ req: Request) async throws -> Void = { _ in } + $0.cognito.deleteUser = mock + } operation: { + try await withApp { app in + try await app.test(.POST, "delete") { res async throws in + let cookie = res.headers.setCookie?["vapor-session"] + #expect(cookie == nil) + } + } + } + } + + @Test func test_delete_throws() async throws { + try await withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request) async throws -> Void = { _ in throw SomeError() } + $0.cognito.deleteUser = mock + $0.environment.dbId = { nil } + } operation: { + try await withApp { app in + try await app.test(.POST, "delete") { res async throws in + #expect(res.status == .internalServerError) + #expect(res.body.string.contains("An unknown error occurred")) + } + } + } + } +} diff --git a/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/HomeIndex_document_development.1.html b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/HomeIndex_document_development.1.html index 5c1d8922f..d9f249ad3 100644 --- a/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/HomeIndex_document_development.1.html +++ b/Tests/AppTests/__Snapshots__/WebpageSnapshotTests/HomeIndex_document_development.1.html @@ -54,6 +54,9 @@

  • FAQ
  • +
  • + Portal +