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
+