From 881858a50820141d04e6577d0b16be901d4be0ba Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 18 Sep 2024 14:49:14 +0100 Subject: [PATCH 01/44] Roll back to Soto 6.x until the Cognito libraries support 7.x. --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index e280e0081..2fc3f3eb1 100644 --- a/Package.swift +++ b/Package.swift @@ -48,6 +48,7 @@ let package = Package( .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.0"), .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), + .package(url: "https://github.com/vapor-community/soto-cognito-authentication.git", from: "4.0.0") ], targets: [ .executableTarget(name: "Run", dependencies: ["App"]), From a11de78114cc7ca6ca3fe5c0814e273af0b5063b Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 18 Sep 2024 08:50:25 -0500 Subject: [PATCH 02/44] first commit --- .../Authentication/LoginController.swift | 8 ++ .../Authentication/PortalController.swift | 16 ++++ .../Authentication/SignupController.swift | 8 ++ .../Authentication/VerifyController.swift | 8 ++ .../App/Views/Authentication/Login+View.swift | 91 +++++++++++++++++++ .../Views/Authentication/Portal+View.swift | 8 ++ .../Views/Authentication/Signup+View.swift | 8 ++ .../Views/Authentication/Verify+View.swift | 8 ++ Sources/Cognito/Cognito.swift | 8 ++ 9 files changed, 163 insertions(+) create mode 100644 Sources/App/Controllers/Authentication/LoginController.swift create mode 100644 Sources/App/Controllers/Authentication/PortalController.swift create mode 100644 Sources/App/Controllers/Authentication/SignupController.swift create mode 100644 Sources/App/Controllers/Authentication/VerifyController.swift create mode 100644 Sources/App/Views/Authentication/Login+View.swift create mode 100644 Sources/App/Views/Authentication/Portal+View.swift create mode 100644 Sources/App/Views/Authentication/Signup+View.swift create mode 100644 Sources/App/Views/Authentication/Verify+View.swift create mode 100644 Sources/Cognito/Cognito.swift diff --git a/Sources/App/Controllers/Authentication/LoginController.swift b/Sources/App/Controllers/Authentication/LoginController.swift new file mode 100644 index 000000000..3e3f15172 --- /dev/null +++ b/Sources/App/Controllers/Authentication/LoginController.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Rahaf Aljerwi on 9/16/24. +// + +import Foundation diff --git a/Sources/App/Controllers/Authentication/PortalController.swift b/Sources/App/Controllers/Authentication/PortalController.swift new file mode 100644 index 000000000..51d77c19a --- /dev/null +++ b/Sources/App/Controllers/Authentication/PortalController.swift @@ -0,0 +1,16 @@ +// +// PortalController.swift +// +// + + +import Fluent +import Plot +import Vapor + +enum PortalController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Portal.View(path: req.url.path).document() + } +} diff --git a/Sources/App/Controllers/Authentication/SignupController.swift b/Sources/App/Controllers/Authentication/SignupController.swift new file mode 100644 index 000000000..1f684a98d --- /dev/null +++ b/Sources/App/Controllers/Authentication/SignupController.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Rahaf Aljerwi on 9/14/24. +// + +import Foundation diff --git a/Sources/App/Controllers/Authentication/VerifyController.swift b/Sources/App/Controllers/Authentication/VerifyController.swift new file mode 100644 index 000000000..da7494811 --- /dev/null +++ b/Sources/App/Controllers/Authentication/VerifyController.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Rahaf Aljerwi on 9/15/24. +// + +import Foundation diff --git a/Sources/App/Views/Authentication/Login+View.swift b/Sources/App/Views/Authentication/Login+View.swift new file mode 100644 index 000000000..c2419c2e2 --- /dev/null +++ b/Sources/App/Views/Authentication/Login+View.swift @@ -0,0 +1,91 @@ +// +// PortalView.swift +// +// + +import Plot +import Foundation + +enum Portal { + + 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( + .h2("Login"), + .loginForm(), + .h2("Dont have an account?"), + .signupButton() + ) + } + } +} + +// move to other file later +extension Portal { + struct Model { + var email: String + var password: String + } +} + +// move to plot extensions later +extension Node where Context: HTML.BodyContext { + static func loginForm(email: String = "", password: String = "") -> Self { + .form( + .action(SiteURL.portal.relativeURL()), + .loginField(email: email), + .passwordField(password: password), + .button( + .type(.submit) + ) + ) + } + + static func signupButton() -> Self { + .form( + .action(SiteURL.signup.relativeURL()), + .button( + .type(.submit) + ) + ) + } +} + +extension Node where Context == HTML.FormContext { + static func loginField(email: String = "") -> Self { + .input( + .id("email"), + .name("email"), + .type(.email), + .placeholder("Enter email"), + .spellcheck(false), + .autocomplete(false), + .value(email) + ) + } + + static func passwordField(password: String = "") -> Self { + .input( + .id("password"), + .name("password"), + .type(.password), + .placeholder("Enter password"), + .spellcheck(false), + .autocomplete(false), + .value(password) + ) + } +} + diff --git a/Sources/App/Views/Authentication/Portal+View.swift b/Sources/App/Views/Authentication/Portal+View.swift new file mode 100644 index 000000000..3e3f15172 --- /dev/null +++ b/Sources/App/Views/Authentication/Portal+View.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Rahaf Aljerwi on 9/16/24. +// + +import Foundation diff --git a/Sources/App/Views/Authentication/Signup+View.swift b/Sources/App/Views/Authentication/Signup+View.swift new file mode 100644 index 000000000..52e0cb47d --- /dev/null +++ b/Sources/App/Views/Authentication/Signup+View.swift @@ -0,0 +1,8 @@ +// +// File 2.swift +// +// +// Created by Rahaf Aljerwi on 9/14/24. +// + +import Foundation diff --git a/Sources/App/Views/Authentication/Verify+View.swift b/Sources/App/Views/Authentication/Verify+View.swift new file mode 100644 index 000000000..269edaa14 --- /dev/null +++ b/Sources/App/Views/Authentication/Verify+View.swift @@ -0,0 +1,8 @@ +// +// File 2.swift +// +// +// Created by Rahaf Aljerwi on 9/16/24. +// + +import Foundation diff --git a/Sources/Cognito/Cognito.swift b/Sources/Cognito/Cognito.swift new file mode 100644 index 000000000..da7494811 --- /dev/null +++ b/Sources/Cognito/Cognito.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Rahaf Aljerwi on 9/15/24. +// + +import Foundation From a5b301bb8818d4b1840b98ff34474dee48a2e780 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Fri, 20 Sep 2024 13:54:46 -0500 Subject: [PATCH 03/44] remove redundant dependancy --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 2fc3f3eb1..e280e0081 100644 --- a/Package.swift +++ b/Package.swift @@ -48,7 +48,6 @@ let package = Package( .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.0"), .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), - .package(url: "https://github.com/vapor-community/soto-cognito-authentication.git", from: "4.0.0") ], targets: [ .executableTarget(name: "Run", dependencies: ["App"]), From 8d4508f862d05177f8f20f95dfab7d88df193757 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 13 Nov 2024 09:06:27 -0600 Subject: [PATCH 04/44] flesh out auth components + minor css --- FrontEnd/main.scss | 1 + FrontEnd/styles/manage.scss | 29 ++++++++ .../Authentication/LoginController.swift | 8 --- .../Authentication/SignupController.swift | 8 --- .../Authentication/VerifyController.swift | 8 --- .../Manage/DeleteAccountController.swift | 19 +++++ .../Manage/ForgotPasswordController.swift | 28 ++++++++ .../Controllers/Manage/LoginController.swift | 51 +++++++++++++ .../Controllers/Manage/LogoutController.swift | 18 +++++ .../PortalController.swift | 7 +- .../Controllers/Manage/ResetController.swift | 34 +++++++++ .../Manage/SessionAuthentication.swift | 28 ++++++++ .../Controllers/Manage/SignupController.swift | 33 +++++++++ .../Controllers/Manage/VerifyController.swift | 34 +++++++++ Sources/App/Core/SiteURL.swift | 42 ++++++++++- .../Views/Authentication/Portal+View.swift | 8 --- .../Views/Authentication/Signup+View.swift | 8 --- .../Views/Authentication/Verify+View.swift | 8 --- .../Views/Blog/BlogActions+Index+View.swift | 6 +- Sources/App/Views/Home/HomeIndex+View.swift | 6 +- .../Views/Manage/ForgotPassword+View.swift | 35 +++++++++ .../Login+View.swift | 72 +++++++++++-------- Sources/App/Views/Manage/Portal+View.swift | 47 ++++++++++++ Sources/App/Views/Manage/Reset+View.swift | 51 +++++++++++++ Sources/App/Views/Manage/Signup+View.swift | 49 +++++++++++++ .../Manage/Successful+Password+Change.swift | 43 +++++++++++ Sources/App/Views/Manage/Verify+View.swift | 70 ++++++++++++++++++ Sources/App/Views/NavMenuItems.swift | 8 +++ Sources/App/Views/PublicPage.swift | 6 +- .../App/Views/Search/SearchShow+View.swift | 6 +- Sources/App/configure.swift | 23 +++++- Sources/App/routes.swift | 54 ++++++++++++++ Sources/Cognito/Cognito.swift | 8 --- 33 files changed, 758 insertions(+), 98 deletions(-) create mode 100644 FrontEnd/styles/manage.scss delete mode 100644 Sources/App/Controllers/Authentication/LoginController.swift delete mode 100644 Sources/App/Controllers/Authentication/SignupController.swift delete mode 100644 Sources/App/Controllers/Authentication/VerifyController.swift create mode 100644 Sources/App/Controllers/Manage/DeleteAccountController.swift create mode 100644 Sources/App/Controllers/Manage/ForgotPasswordController.swift create mode 100644 Sources/App/Controllers/Manage/LoginController.swift create mode 100644 Sources/App/Controllers/Manage/LogoutController.swift rename Sources/App/Controllers/{Authentication => Manage}/PortalController.swift (83%) create mode 100644 Sources/App/Controllers/Manage/ResetController.swift create mode 100644 Sources/App/Controllers/Manage/SessionAuthentication.swift create mode 100644 Sources/App/Controllers/Manage/SignupController.swift create mode 100644 Sources/App/Controllers/Manage/VerifyController.swift delete mode 100644 Sources/App/Views/Authentication/Portal+View.swift delete mode 100644 Sources/App/Views/Authentication/Signup+View.swift delete mode 100644 Sources/App/Views/Authentication/Verify+View.swift create mode 100644 Sources/App/Views/Manage/ForgotPassword+View.swift rename Sources/App/Views/{Authentication => Manage}/Login+View.swift (50%) create mode 100644 Sources/App/Views/Manage/Portal+View.swift create mode 100644 Sources/App/Views/Manage/Reset+View.swift create mode 100644 Sources/App/Views/Manage/Signup+View.swift create mode 100644 Sources/App/Views/Manage/Successful+Password+Change.swift create mode 100644 Sources/App/Views/Manage/Verify+View.swift delete mode 100644 Sources/Cognito/Cognito.swift diff --git a/FrontEnd/main.scss b/FrontEnd/main.scss index a3f98f559..feb6033ee 100644 --- a/FrontEnd/main.scss +++ b/FrontEnd/main.scss @@ -46,3 +46,4 @@ $mobile-breakpoint: 740px; @import 'styles/tab_bar'; @import 'styles/validate_manifest'; @import 'styles/vega_charts'; +@import 'styles/manage'; diff --git a/FrontEnd/styles/manage.scss b/FrontEnd/styles/manage.scss new file mode 100644 index 000000000..7bc179242 --- /dev/null +++ b/FrontEnd/styles/manage.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.) +// ------------------------------------------------------------------------- + +.manage-page { + height: 55vh; + padding-top: 10%; +} + +.manage-form-inputs { + display: flex; + flex-direction: column; + width: 50%; + margin-bottom: 15px; +} diff --git a/Sources/App/Controllers/Authentication/LoginController.swift b/Sources/App/Controllers/Authentication/LoginController.swift deleted file mode 100644 index 3e3f15172..000000000 --- a/Sources/App/Controllers/Authentication/LoginController.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// -// -// Created by Rahaf Aljerwi on 9/16/24. -// - -import Foundation diff --git a/Sources/App/Controllers/Authentication/SignupController.swift b/Sources/App/Controllers/Authentication/SignupController.swift deleted file mode 100644 index 1f684a98d..000000000 --- a/Sources/App/Controllers/Authentication/SignupController.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// -// -// Created by Rahaf Aljerwi on 9/14/24. -// - -import Foundation diff --git a/Sources/App/Controllers/Authentication/VerifyController.swift b/Sources/App/Controllers/Authentication/VerifyController.swift deleted file mode 100644 index da7494811..000000000 --- a/Sources/App/Controllers/Authentication/VerifyController.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// -// -// Created by Rahaf Aljerwi on 9/15/24. -// - -import Foundation diff --git a/Sources/App/Controllers/Manage/DeleteAccountController.swift b/Sources/App/Controllers/Manage/DeleteAccountController.swift new file mode 100644 index 000000000..a4e17a70d --- /dev/null +++ b/Sources/App/Controllers/Manage/DeleteAccountController.swift @@ -0,0 +1,19 @@ +import Foundation +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +enum DeleteAccountController { + @Sendable + static func deleteAccount(req: Request) async throws -> Response { + let request = try CognitoIdentityProvider.DeleteUserRequest(accessToken: req.auth.require(AuthenticatedUser.self).sessionID) + try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) + 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/Manage/ForgotPasswordController.swift b/Sources/App/Controllers/Manage/ForgotPasswordController.swift new file mode 100644 index 000000000..bf928085f --- /dev/null +++ b/Sources/App/Controllers/Manage/ForgotPasswordController.swift @@ -0,0 +1,28 @@ +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +enum ForgotPasswordController { + @Sendable + static func show(req: Request) async throws -> HTML { + return ForgotPassword.View(path: req.url.path).document() + } + + @Sendable + static func forgotPasswordEmail(req: Request) async throws -> HTML { + struct Credentials: Content { + var email: String + } + let user = try req.content.decode(Credentials.self) + do { + try await req.application.cognito.authenticatable.forgotPassword(username: user.email) + return Reset.View(path: SiteURL.resetPassword.relativeURL(), model: Reset.Model(email: user.email)).document() + } catch { + // TODO: handle this + return Reset.View(path: SiteURL.resetPassword.relativeURL(), model: Reset.Model(email: user.email)).document() + } + } +} diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift new file mode 100644 index 000000000..b480ff4c1 --- /dev/null +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -0,0 +1,51 @@ +import Foundation +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +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 { + struct UserCreds: Content { + var email: String + var password: String + } + let user = try req.content.decode(UserCreds.self) + + do { + let response = try await req.application.cognito.authenticatable.authenticate(username: user.email, password: user.password, context: req) + switch response { + case .authenticated(let authenticatedResponse): + let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!, refreshToken: authenticatedResponse.refreshToken!) + req.auth.login(user) + case .challenged(let challengedResponse): // TODO: handle challenge + 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 { + return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An unknown error occurred. Please try again.")).document().encodeResponse(status: .unauthorized) + } + + } +} + diff --git a/Sources/App/Controllers/Manage/LogoutController.swift b/Sources/App/Controllers/Manage/LogoutController.swift new file mode 100644 index 000000000..2ba2cb4db --- /dev/null +++ b/Sources/App/Controllers/Manage/LogoutController.swift @@ -0,0 +1,18 @@ +import Foundation +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +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/Authentication/PortalController.swift b/Sources/App/Controllers/Manage/PortalController.swift similarity index 83% rename from Sources/App/Controllers/Authentication/PortalController.swift rename to Sources/App/Controllers/Manage/PortalController.swift index 51d77c19a..7560e4b04 100644 --- a/Sources/App/Controllers/Authentication/PortalController.swift +++ b/Sources/App/Controllers/Manage/PortalController.swift @@ -1,12 +1,7 @@ -// -// PortalController.swift -// -// - - import Fluent import Plot import Vapor +import SotoCognitoAuthenticationKit enum PortalController { @Sendable diff --git a/Sources/App/Controllers/Manage/ResetController.swift b/Sources/App/Controllers/Manage/ResetController.swift new file mode 100644 index 000000000..00a1ecba7 --- /dev/null +++ b/Sources/App/Controllers/Manage/ResetController.swift @@ -0,0 +1,34 @@ +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +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 { + struct UserInfo: Content { + var email: String + var password: String + var confirmationCode: String + } + let user = try req.content.decode(UserInfo.self) + do { + try await req.application.cognito.authenticatable.confirmForgotPassword(username: user.email, newPassword: 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 model = Reset.Model(errorMessage: error.message ?? "There was an error.") + return Reset.View(path: req.url.path, model: model).document() + } catch { + let model = Reset.Model(errorMessage: "An unknown error occurred.") + return Reset.View(path: req.url.path, model: model).document() + } + } +} diff --git a/Sources/App/Controllers/Manage/SessionAuthentication.swift b/Sources/App/Controllers/Manage/SessionAuthentication.swift new file mode 100644 index 000000000..bfadfa107 --- /dev/null +++ b/Sources/App/Controllers/Manage/SessionAuthentication.swift @@ -0,0 +1,28 @@ +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +struct AuthenticatedUser { + var accessToken: String + var refreshToken: String? +} + +extension AuthenticatedUser: SessionAuthenticatable { + var sessionID: String { + self.accessToken + } +} + +struct UserSessionAuthenticator: AsyncSessionAuthenticator { + func authenticate(sessionID: String, for request: Vapor.Request) async throws { + do { + // TODO: handle response, refresh token + let response = try await request.application.cognito.authenticatable.authenticate(accessToken: sessionID, on: request.eventLoop) + request.auth.login(User(accessToken: sessionID)) + } catch let error as SotoCognitoError { // TODO: handle error + return + } + } + typealias User = AuthenticatedUser +} diff --git a/Sources/App/Controllers/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift new file mode 100644 index 000000000..ed77e3a38 --- /dev/null +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -0,0 +1,33 @@ +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +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 { + struct UserCreds: Content { + var email: String + var password: String + } + let user = try req.content.decode(UserCreds.self) + do { + let _ = try await req.application.cognito.authenticatable.signUp(username: user.email, password: user.password, attributes: [:], on:req.eventLoop) + return Verify.View(path: SiteURL.verify.relativeURL(), model: Verify.Model(email: user.email)).document() + } catch let error as AWSErrorType { + let model = Signup.Model(errorMessage: error.message ?? "There was an error.") + 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.")).document() + } + + } +} + diff --git a/Sources/App/Controllers/Manage/VerifyController.swift b/Sources/App/Controllers/Manage/VerifyController.swift new file mode 100644 index 000000000..3d1208fe0 --- /dev/null +++ b/Sources/App/Controllers/Manage/VerifyController.swift @@ -0,0 +1,34 @@ + +import Fluent +import Plot +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +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 { + struct VerifyInformation: Content { + var email: String + var confirmationCode: String + } + let info = try req.content.decode(VerifyInformation.self) + do { + try await req.application.cognito.authenticatable.confirmSignUp(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 model = Verify.Model(email: info.email, errorMessage: error.message ?? "There was an error.") + return Verify.View(path: req.url.path, model: model).document() + } catch { + let model = Verify.Model(email: info.email, errorMessage: "An unknown error occurred.") + return Verify.View(path: req.url.path, model: model).document() + } + } +} diff --git a/Sources/App/Core/SiteURL.swift b/Sources/App/Core/SiteURL.swift index 7a80bf335..a6b447c7a 100644 --- a/Sources/App/Core/SiteURL.swift +++ b/Sources/App/Core/SiteURL.swift @@ -113,26 +113,34 @@ enum SiteURL: Resourceable, Sendable { case blogPost(_ slug: Parameter) case buildMonitor case builds(_ id: 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 packageCollection(_ owner: 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) case supporters case tryInPlayground case validateSPIManifest + case verify var path: String { switch self { @@ -168,12 +176,18 @@ enum SiteURL: Resourceable, Sendable { case .buildMonitor: return "build-monitor" + + case .deleteAccount: + return "delete" case let .docs(next): return "docs/\(next.path)" case .faq: return "faq" + + case .forgotPassword: + return "forgot-password" case .home: return "" @@ -189,6 +203,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 @@ -209,12 +229,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" case .rssPackages: return "packages.rss" @@ -224,6 +250,9 @@ enum SiteURL: Resourceable, Sendable { case .search: return "search" + + case .signup: + return "signup" case .siteMapIndex: return "sitemap.xml" @@ -242,6 +271,9 @@ enum SiteURL: Resourceable, Sendable { case .validateSPIManifest: return "validate-spi-manifest" + + case .verify: + return "verify" } } @@ -250,19 +282,27 @@ 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, - .validateSPIManifest: + .validateSPIManifest, + .verify: return [.init(stringLiteral: path)] case let .api(next): diff --git a/Sources/App/Views/Authentication/Portal+View.swift b/Sources/App/Views/Authentication/Portal+View.swift deleted file mode 100644 index 3e3f15172..000000000 --- a/Sources/App/Views/Authentication/Portal+View.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// -// -// Created by Rahaf Aljerwi on 9/16/24. -// - -import Foundation diff --git a/Sources/App/Views/Authentication/Signup+View.swift b/Sources/App/Views/Authentication/Signup+View.swift deleted file mode 100644 index 52e0cb47d..000000000 --- a/Sources/App/Views/Authentication/Signup+View.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File 2.swift -// -// -// Created by Rahaf Aljerwi on 9/14/24. -// - -import Foundation diff --git a/Sources/App/Views/Authentication/Verify+View.swift b/Sources/App/Views/Authentication/Verify+View.swift deleted file mode 100644 index 269edaa14..000000000 --- a/Sources/App/Views/Authentication/Verify+View.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File 2.swift -// -// -// Created by Rahaf Aljerwi on 9/16/24. -// - -import Foundation diff --git a/Sources/App/Views/Blog/BlogActions+Index+View.swift b/Sources/App/Views/Blog/BlogActions+Index+View.swift index 763a33f8f..17a5ced6f 100644 --- a/Sources/App/Views/Blog/BlogActions+Index+View.swift +++ b/Sources/App/Views/Blog/BlogActions+Index+View.swift @@ -108,7 +108,11 @@ extension BlogActions { } override func navMenuItems() -> [NavMenuItem] { - [.supporters, .searchLink, .addPackage, .faq] + if Current.environment() == .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 0aa2fa8bf..4cf7f1a61 100644 --- a/Sources/App/Views/Home/HomeIndex+View.swift +++ b/Sources/App/Views/Home/HomeIndex+View.swift @@ -114,7 +114,11 @@ enum HomeIndex { } override func navMenuItems() -> [NavMenuItem] { - [.supporters, .addPackage, .blog, .faq] + if Current.environment() == .production { + [.supporters, .addPackage, .blog, .faq] + } else { + [.supporters, .addPackage, .blog, .faq, .portal] + } } } } diff --git a/Sources/App/Views/Manage/ForgotPassword+View.swift b/Sources/App/Views/Manage/ForgotPassword+View.swift new file mode 100644 index 000000000..b0e6e7d3c --- /dev/null +++ b/Sources/App/Views/Manage/ForgotPassword+View.swift @@ -0,0 +1,35 @@ +import Plot +import Foundation + +enum ForgotPassword { + + class View: PublicPage { + + override func pageTitle() -> String? { + "Forgot Password" + } + + override func content() -> Node { + .div( + .class("manage-page"), + .h2("An email will be sent with a reset code"), + .forgotPasswordForm() + ) + } + } +} + +// TODO: move to plot extensions +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/Authentication/Login+View.swift b/Sources/App/Views/Manage/Login+View.swift similarity index 50% rename from Sources/App/Views/Authentication/Login+View.swift rename to Sources/App/Views/Manage/Login+View.swift index c2419c2e2..08246da36 100644 --- a/Sources/App/Views/Authentication/Login+View.swift +++ b/Sources/App/Views/Manage/Login+View.swift @@ -1,62 +1,72 @@ -// -// PortalView.swift -// -// - import Plot import Foundation -enum Portal { +enum Login { + + struct Model { + var errorMessage: String = "" + } class View: PublicPage { - // let model: Model + let model: Model -// init(path: String, model: Model) { -// self.model = model -// super.init(path: path) -// } + init(path: String, model: Model) { + self.model = model + super.init(path: path) + } override func pageTitle() -> String? { - "Portal" + "Log in" } override func content() -> Node { .div( .h2("Login"), .loginForm(), + .text(model.errorMessage), .h2("Dont have an account?"), - .signupButton() + .signupButton(), + .h2("Forgot password?"), + .forgotPassword() ) } } } -// move to other file later -extension Portal { - struct Model { - var email: String - var password: String - } -} - -// move to plot extensions later +// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { static func loginForm(email: String = "", password: String = "") -> Self { + .div( + .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() -> Self { .form( - .action(SiteURL.portal.relativeURL()), - .loginField(email: email), - .passwordField(password: password), + .action(SiteURL.signup.relativeURL()), .button( + .text("Create an account"), .type(.submit) ) ) } - static func signupButton() -> Self { + static func forgotPassword() -> Self { .form( - .action(SiteURL.signup.relativeURL()), + .action(SiteURL.forgotPassword.relativeURL()), .button( + .text("Reset password"), .type(.submit) ) ) @@ -64,8 +74,9 @@ extension Node where Context: HTML.BodyContext { } extension Node where Context == HTML.FormContext { - static func loginField(email: String = "") -> Self { + static func emailField(email: String = "") -> Self { .input( + .class("manage-form-inputs"), .id("email"), .name("email"), .type(.email), @@ -76,12 +87,13 @@ extension Node where Context == HTML.FormContext { ) } - static func passwordField(password: String = "") -> Self { + static func passwordField(password: String = "", passwordFieldText: String = "Enter password") -> Self { .input( + .class("manage-form-inputs"), .id("password"), .name("password"), .type(.password), - .placeholder("Enter password"), + .placeholder(passwordFieldText), .spellcheck(false), .autocomplete(false), .value(password) diff --git a/Sources/App/Views/Manage/Portal+View.swift b/Sources/App/Views/Manage/Portal+View.swift new file mode 100644 index 000000000..f5334fe9e --- /dev/null +++ b/Sources/App/Views/Manage/Portal+View.swift @@ -0,0 +1,47 @@ +import Plot +import Foundation + +enum Portal { + + class View: PublicPage { + + override func pageTitle() -> String? { + "Portal" + } + + override func content() -> Node { + .div( + .h2("Portal"), + .logoutButton(), + .deleteButton() + ) + } + } +} + +// TODO: move to plot extensions +extension Node where Context: HTML.BodyContext { + static func logoutButton() -> Self { + .form( + .action(SiteURL.logout.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .button( + .type(.submit), + .text("logout") + ) + ) + } + + static func deleteButton() -> Self { + .form( + .action(SiteURL.deleteAccount.relativeURL()), + .method(.post), + .data(named: "turbo", value: "false"), + .button( + .type(.submit), + .text("delete account") + ) + ) + } +} diff --git a/Sources/App/Views/Manage/Reset+View.swift b/Sources/App/Views/Manage/Reset+View.swift new file mode 100644 index 000000000..399e9513a --- /dev/null +++ b/Sources/App/Views/Manage/Reset+View.swift @@ -0,0 +1,51 @@ +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) + ) + } + } +} + +// TODO: move to plot extensions +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"), + .codeField(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/Manage/Signup+View.swift b/Sources/App/Views/Manage/Signup+View.swift new file mode 100644 index 000000000..f775353dc --- /dev/null +++ b/Sources/App/Views/Manage/Signup+View.swift @@ -0,0 +1,49 @@ +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("manage-page"), + .h2("Signup"), + .signupForm(), + .text(model.errorMessage) + ) + } + } +} + +// TODO: move to plot extensions +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/Manage/Successful+Password+Change.swift b/Sources/App/Views/Manage/Successful+Password+Change.swift new file mode 100644 index 000000000..1f025c3b4 --- /dev/null +++ b/Sources/App/Views/Manage/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( + .text(self.model.successMessage), + .loginButton() + ) + } + } +} + +// TODO: move to plot extensions +extension Node where Context: HTML.BodyContext { + static func loginButton() -> Self { + .form( + .action(SiteURL.login.relativeURL()), + .button( + .text("Login"), + .type(.submit) + ) + ) + } +} diff --git a/Sources/App/Views/Manage/Verify+View.swift b/Sources/App/Views/Manage/Verify+View.swift new file mode 100644 index 000000000..f56236e3c --- /dev/null +++ b/Sources/App/Views/Manage/Verify+View.swift @@ -0,0 +1,70 @@ +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("manage-page"), + .h2("Please enter the confirmation code sent to your email"), + .verifyForm(email: model.email), + .text(model.errorMessage) + ) + } + } +} + +// TODO: move to plot extensions +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) + ), + .codeField(code: code), + .data(named: "turbo", value: "false"), + .button( + .text("Confirm sign up"), + .type(.submit) + ) + ) + } +} + +extension Node where Context == HTML.FormContext { + static func codeField(code: String = "") -> Self { + .input( + .class("manage-form-inputs"), + .id("confirmationCode"), + .name("confirmationCode"), + .type(.text), + .placeholder("Confirmation code"), + .spellcheck(false), + .autocomplete(false), + .value(code) + ) + } +} diff --git a/Sources/App/Views/NavMenuItems.swift b/Sources/App/Views/NavMenuItems.swift index 182fe094f..303a30016 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 { @@ -65,6 +66,13 @@ enum NavMenuItem { "Search Packages" ) ) + case .portal: + return .li( + .a( + .href(SiteURL.portal.relativeURL()), + "Portal" + ) + ) } } } diff --git a/Sources/App/Views/PublicPage.swift b/Sources/App/Views/PublicPage.swift index 06f209b16..61c5d09f9 100644 --- a/Sources/App/Views/PublicPage.swift +++ b/Sources/App/Views/PublicPage.swift @@ -316,7 +316,11 @@ 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] + if Current.environment() == .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..a601020bc 100644 --- a/Sources/App/Views/Search/SearchShow+View.swift +++ b/Sources/App/Views/Search/SearchShow+View.swift @@ -64,7 +64,11 @@ extension SearchShow { } override func navMenuItems() -> [NavMenuItem] { - [.supporters, .addPackage, .blog, .faq] + if Current.environment() == .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 28cbd3fb6..11937b21b 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -15,6 +15,9 @@ import Fluent import FluentPostgresDriver import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity @discardableResult public func configure(_ app: Application) async throws -> String { @@ -75,7 +78,25 @@ public func configure(_ app: Application) async throws -> String { // Set sqlLogLevel to .info to log SQL queries with the default log level. sqlLogLevel: .debug), as: .psql) - + + app.sessions.use(.memory) + + let awsClient = AWSClient(httpClientProvider: .shared(app.http.client.shared)) + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("POOL_ID")!, + clientId: Environment.get("CLIENT_ID")!, + clientSecret: Environment.get("CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + app.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) + + + // 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 22358d0df..f936e6f56 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -125,6 +125,60 @@ func routes(_ app: Application) throws { app.post(SiteURL.validateSPIManifest.pathComponents, use: ValidateSPIManifestController.validate) .excludeFromOpenAPI() } + + let auth = app.routes.grouped([app.sessions.middleware, UserSessionAuthenticator()]) + let redirect = auth.grouped(AuthenticatedUser.redirectMiddleware(path: SiteURL.login.relativeURL())) + + if Current.environment() != .production { + do { + redirect.get(SiteURL.portal.pathComponents, use: PortalController.show) + .excludeFromOpenAPI() + } + + do { + auth.get(SiteURL.login.pathComponents, use: LoginController.show) + auth.post(SiteURL.login.pathComponents, use: LoginController.login) + .excludeFromOpenAPI() + } + + do { + auth.get(SiteURL.signup.pathComponents, use: SignupController.show) + .excludeFromOpenAPI() + auth.post(SiteURL.signup.pathComponents, use: SignupController.signup) + .excludeFromOpenAPI() + } + + do { + auth.get(SiteURL.verify.pathComponents, use: VerifyController.show) + .excludeFromOpenAPI() + auth.post(SiteURL.verify.pathComponents, use: VerifyController.verify) + .excludeFromOpenAPI() + } + + do { + auth.post(SiteURL.logout.pathComponents, use: LogoutController.logout) + .excludeFromOpenAPI() + } + + do { + auth.post(SiteURL.deleteAccount.pathComponents, use: DeleteAccountController.deleteAccount) + .excludeFromOpenAPI() + } + + do { + app.get(SiteURL.forgotPassword.pathComponents, use: ForgotPasswordController.show) + .excludeFromOpenAPI() + app.post(SiteURL.forgotPassword.pathComponents, use: ForgotPasswordController.forgotPasswordEmail) + .excludeFromOpenAPI() + } + + do { + app.get(SiteURL.resetPassword.pathComponents, use: ResetController.show) + .excludeFromOpenAPI() + app.post(SiteURL.resetPassword.pathComponents, use: ResetController.resetPassword) + .excludeFromOpenAPI() + } + } // Ready for Swift 6 app.get(SiteURL.readyForSwift6.pathComponents, use: ReadyForSwift6Controller.show) diff --git a/Sources/Cognito/Cognito.swift b/Sources/Cognito/Cognito.swift deleted file mode 100644 index da7494811..000000000 --- a/Sources/Cognito/Cognito.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// -// -// Created by Rahaf Aljerwi on 9/15/24. -// - -import Foundation From 2bae5b751eec5d55997c3880d268fa44c46b3ebf Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Fri, 15 Nov 2024 01:35:27 -0600 Subject: [PATCH 05/44] display error message in forgot password --- .../Manage/ForgotPasswordController.swift | 5 ++--- Sources/App/Views/Manage/ForgotPassword+View.swift | 14 +++++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Sources/App/Controllers/Manage/ForgotPasswordController.swift b/Sources/App/Controllers/Manage/ForgotPasswordController.swift index bf928085f..7ce14a38d 100644 --- a/Sources/App/Controllers/Manage/ForgotPasswordController.swift +++ b/Sources/App/Controllers/Manage/ForgotPasswordController.swift @@ -8,7 +8,7 @@ import SotoCognitoIdentity enum ForgotPasswordController { @Sendable static func show(req: Request) async throws -> HTML { - return ForgotPassword.View(path: req.url.path).document() + return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model()).document() } @Sendable @@ -21,8 +21,7 @@ enum ForgotPasswordController { try await req.application.cognito.authenticatable.forgotPassword(username: user.email) return Reset.View(path: SiteURL.resetPassword.relativeURL(), model: Reset.Model(email: user.email)).document() } catch { - // TODO: handle this - return Reset.View(path: SiteURL.resetPassword.relativeURL(), model: Reset.Model(email: user.email)).document() + return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model(errorMessage: "There was an error. Please try again.")).document() } } } diff --git a/Sources/App/Views/Manage/ForgotPassword+View.swift b/Sources/App/Views/Manage/ForgotPassword+View.swift index b0e6e7d3c..45f45f303 100644 --- a/Sources/App/Views/Manage/ForgotPassword+View.swift +++ b/Sources/App/Views/Manage/ForgotPassword+View.swift @@ -3,8 +3,19 @@ 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" } @@ -13,7 +24,8 @@ enum ForgotPassword { .div( .class("manage-page"), .h2("An email will be sent with a reset code"), - .forgotPasswordForm() + .forgotPasswordForm(), + .text(model.errorMessage) ) } } From b56cb1ad1f0f3f5e6ffeeb1cc765ed12b0a31c8c Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 20 Nov 2024 14:30:53 +0000 Subject: [PATCH 06/44] Added more error information when we get a non-AWS cognito error. --- Sources/App/Controllers/Manage/SignupController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/App/Controllers/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift index ed77e3a38..fd22a1374 100644 --- a/Sources/App/Controllers/Manage/SignupController.swift +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -25,7 +25,7 @@ enum SignupController { let model = Signup.Model(errorMessage: error.message ?? "There was an error.") 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.")).document() + return Signup.View(path: SiteURL.signup.relativeURL(), model: Signup.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document() } } From 9a8532a42377ebccf3dd05cc05f9d717f8bef013 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 20 Nov 2024 15:54:46 +0000 Subject: [PATCH 07/44] Added some additional error logging. --- Sources/App/Controllers/Manage/LoginController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index b480ff4c1..2d457a735 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -42,8 +42,10 @@ enum LoginController { break } return Login.View(path: req.url.path, model: model).document().encodeResponse(status: .unauthorized) + } catch let error as AWSClientError { + return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An AWS client error occurred: \(error.errorCode)")).document().encodeResponse(status: .unauthorized) } catch { - return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An unknown error occurred. Please try again.")).document().encodeResponse(status: .unauthorized) + return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .unauthorized) } } From 9e75369c5e8d98b74a45f070672143e8e8af06f0 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 26 Nov 2024 21:44:05 -0600 Subject: [PATCH 08/44] Current -> environment --- Sources/App/Views/Blog/BlogActions+Index+View.swift | 4 +++- Sources/App/Views/Home/HomeIndex+View.swift | 8 +++++--- Sources/App/Views/PublicPage.swift | 3 ++- Sources/App/Views/Search/SearchShow+View.swift | 4 +++- Sources/App/routes.swift | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/App/Views/Blog/BlogActions+Index+View.swift b/Sources/App/Views/Blog/BlogActions+Index+View.swift index 17a5ced6f..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,8 @@ extension BlogActions { } override func navMenuItems() -> [NavMenuItem] { - if Current.environment() == .production { + @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 0f4563d7d..0546c9328 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 { @@ -114,10 +115,11 @@ enum HomeIndex { } override func navMenuItems() -> [NavMenuItem] { - if Current.environment() == .production { - [.supporters, .addPackage, .blog, .faq] + @Dependency(\.environment) var environment + if environment.current() == .production { + return [.supporters, .addPackage, .blog, .faq] } else { - [.supporters, .addPackage, .blog, .faq, .portal] + return [.supporters, .addPackage, .blog, .faq, .portal] } } } diff --git a/Sources/App/Views/PublicPage.swift b/Sources/App/Views/PublicPage.swift index b3bf1b8ad..73591cb45 100644 --- a/Sources/App/Views/PublicPage.swift +++ b/Sources/App/Views/PublicPage.swift @@ -323,7 +323,8 @@ class PublicPage { /// The items to be rendered in the site navigation menu. /// - Returns: An array of `NavMenuItem` items used in `header`. func navMenuItems() -> [NavMenuItem] { - if Current.environment() == .production { + @Dependency(\.environment) var environment + if environment.current() == .production { return [.supporters, .addPackage, .blog, .faq, .search] } else { return [.supporters, .addPackage, .blog, .faq, .search, .portal] diff --git a/Sources/App/Views/Search/SearchShow+View.swift b/Sources/App/Views/Search/SearchShow+View.swift index a601020bc..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,8 @@ extension SearchShow { } override func navMenuItems() -> [NavMenuItem] { - if Current.environment() == .production { + @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/routes.swift b/Sources/App/routes.swift index 89c85f77b..daaf4966e 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -138,7 +138,7 @@ func routes(_ app: Application) throws { let auth = app.routes.grouped([app.sessions.middleware, UserSessionAuthenticator()]) let redirect = auth.grouped(AuthenticatedUser.redirectMiddleware(path: SiteURL.login.relativeURL())) - if Current.environment() != .production { + if environment.current() != .production { do { redirect.get(SiteURL.portal.pathComponents, use: PortalController.show) .excludeFromOpenAPI() From dfc3ebd9a34f590b6c12417a875426d5724a443d Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 27 Nov 2024 14:48:13 -0600 Subject: [PATCH 09/44] seperate Cognito auth logic --- Sources/App/Controllers/Manage/Cognito.swift | 28 +++++++++++++++++++ .../Controllers/Manage/LoginController.swift | 13 ++------- 2 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 Sources/App/Controllers/Manage/Cognito.swift diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift new file mode 100644 index 000000000..25935ba18 --- /dev/null +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -0,0 +1,28 @@ +import Vapor +import SotoCognitoAuthentication +import SotoCognitoIdentityProvider +import SotoCognitoIdentity + +struct Cognito { + @Sendable + static func authenticate(req: Request, username: String, password: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("POOL_ID")!, + clientId: Environment.get("CLIENT_ID")!, + clientSecret: Environment.get("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) + switch response { + case .authenticated(let authenticatedResponse): + let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!, refreshToken: authenticatedResponse.refreshToken!) + req.auth.login(user) + case .challenged(let challengedResponse): // TODO: handle challenge + break + } + try awsClient.syncShutdown() + } +} diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index 2d457a735..45ffe9d73 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -18,21 +18,12 @@ enum LoginController { var email: String var password: String } - let user = try req.content.decode(UserCreds.self) - do { - let response = try await req.application.cognito.authenticatable.authenticate(username: user.email, password: user.password, context: req) - switch response { - case .authenticated(let authenticatedResponse): - let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!, refreshToken: authenticatedResponse.refreshToken!) - req.auth.login(user) - case .challenged(let challengedResponse): // TODO: handle challenge - break - } + let user = try req.content.decode(UserCreds.self) + try await Cognito.authenticate(req: req, username: user.email, password: user.password) 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.") From 803cd82a909f895b48545a43262d3bf97126ed94 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sat, 30 Nov 2024 01:33:44 -0600 Subject: [PATCH 10/44] basic login tests --- .../Controllers/Manage/LoginController.swift | 17 +++++- .../App/Core/Dependencies/CognitoClient.swift | 42 ++++++++++++++ Sources/App/configure.swift | 10 ---- Tests/AppTests/ManageTests.swift | 57 +++++++++++++++++++ 4 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 Sources/App/Core/Dependencies/CognitoClient.swift create mode 100644 Tests/AppTests/ManageTests.swift diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index 45ffe9d73..5ff5e0e34 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -1,4 +1,5 @@ import Foundation +import Dependencies import Fluent import Plot import Vapor @@ -14,13 +15,24 @@ enum LoginController { @Sendable static func login(req: Request) async throws -> Response { + @Dependency(\.cognito) var cognito + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("POOL_ID")!, + clientId: Environment.get("CLIENT_ID")!, + clientSecret: Environment.get("CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) struct UserCreds: Content { var email: String var password: String } do { let user = try req.content.decode(UserCreds.self) - try await Cognito.authenticate(req: req, username: user.email, password: user.password) + try await cognito.authenticate(req: req, username: user.email, password: user.password) + try await awsClient.shutdown() 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.") @@ -32,10 +44,13 @@ enum LoginController { case .invalidPublicKey: break } + try await awsClient.shutdown() return Login.View(path: req.url.path, model: model).document().encodeResponse(status: .unauthorized) } catch let error as AWSClientError { + try await awsClient.shutdown() return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An AWS client error occurred: \(error.errorCode)")).document().encodeResponse(status: .unauthorized) } catch { + try await awsClient.shutdown() return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .unauthorized) } diff --git a/Sources/App/Core/Dependencies/CognitoClient.swift b/Sources/App/Core/Dependencies/CognitoClient.swift new file mode 100644 index 000000000..18a8dd3f7 --- /dev/null +++ b/Sources/App/Core/Dependencies/CognitoClient.swift @@ -0,0 +1,42 @@ +// 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 + +@DependencyClient +struct CognitoClient { + var authenticate: @Sendable (_ req: Request, _ username: String, _ password: String) 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) } + ) + } +} + +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/configure.swift b/Sources/App/configure.swift index 9bc52393a..d11b6423e 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -81,16 +81,6 @@ public func configure(_ app: Application) async throws -> String { app.sessions.use(.memory) - let awsClient = AWSClient(httpClientProvider: .shared(app.http.client.shared)) - let awsCognitoConfiguration = CognitoConfiguration( - userPoolId: Environment.get("POOL_ID")!, - clientId: Environment.get("CLIENT_ID")!, - clientSecret: Environment.get("CLIENT_SECRET")!, - cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), - adminClient: true - ) - app.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) - // Configures cookie value creation. app.sessions.configuration.cookieFactory = { sessionID in diff --git a/Tests/AppTests/ManageTests.swift b/Tests/AppTests/ManageTests.swift new file mode 100644 index 000000000..511060a15 --- /dev/null +++ b/Tests/AppTests/ManageTests.swift @@ -0,0 +1,57 @@ +// 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 XCTest +import Fluent +import Vapor +import Dependencies + + +class ManageTests: AppTestCase { + + func test_portal_route_protected() throws { + try app.test(.GET, "portal") { res in + XCTAssertEqual(res.status, .seeOther) + } + } + + func test_login_successful_redirect() throws { + // not throwing in auth is a successful authentication and user + // should be redirected + try withDependencies { + var mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in } + $0.cognito.authenticate = mock + } operation: { + try app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .seeOther) + }) + } + } + + func test_login_throws() throws { + struct SomeError: Error {} + try withDependencies { + var mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in throw SomeError() } + $0.cognito.authenticate = mock + } operation: { + try app.test(.POST, "login") { res in + XCTAssertEqual(res.status, .unauthorized) + } + } + } +} + From 608a520d236ef56f64eb160ce02e2ee33d5a08ea Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sun, 1 Dec 2024 23:34:49 -0600 Subject: [PATCH 11/44] separate cognito sign up function --- Sources/App/Controllers/Manage/Cognito.swift | 15 +++++++++++++++ .../App/Controllers/Manage/SignupController.swift | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index 25935ba18..1854fb3d3 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -25,4 +25,19 @@ struct Cognito { } try awsClient.syncShutdown() } + + @Sendable + static func signup(req: Request, username: String, password: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("POOL_ID")!, + clientId: Environment.get("CLIENT_ID")!, + clientSecret: Environment.get("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 awsClient.syncShutdown() + } } diff --git a/Sources/App/Controllers/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift index fd22a1374..e6f724bff 100644 --- a/Sources/App/Controllers/Manage/SignupController.swift +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -17,9 +17,9 @@ enum SignupController { var email: String var password: String } - let user = try req.content.decode(UserCreds.self) do { - let _ = try await req.application.cognito.authenticatable.signUp(username: user.email, password: user.password, attributes: [:], on:req.eventLoop) + 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 model = Signup.Model(errorMessage: error.message ?? "There was an error.") From 98ae917bb481fe3107734e596eb65c02010f022a Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sun, 1 Dec 2024 23:43:33 -0600 Subject: [PATCH 12/44] add support for testing sign up --- Sources/App/Controllers/Manage/SignupController.swift | 4 +++- Sources/App/Core/Dependencies/CognitoClient.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/App/Controllers/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift index e6f724bff..d0a324060 100644 --- a/Sources/App/Controllers/Manage/SignupController.swift +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -1,4 +1,5 @@ import Fluent +import Dependencies import Plot import Vapor import SotoCognitoAuthentication @@ -13,13 +14,14 @@ enum SignupController { @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) + 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 model = Signup.Model(errorMessage: error.message ?? "There was an error.") diff --git a/Sources/App/Core/Dependencies/CognitoClient.swift b/Sources/App/Core/Dependencies/CognitoClient.swift index 18a8dd3f7..11457ec8d 100644 --- a/Sources/App/Core/Dependencies/CognitoClient.swift +++ b/Sources/App/Core/Dependencies/CognitoClient.swift @@ -19,12 +19,14 @@ import Vapor @DependencyClient struct CognitoClient { var authenticate: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void + var signup: @Sendable (_ req: Request, _ username: String, _ password: String) 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) } + authenticate: { req, username, password in try await Cognito.authenticate(req: req, username: username, password: password) }, + signup : { req, username, password in try await Cognito.signup(req: req, username: username, password: password) } ) } } From 78ef4d46f8b2aa452449ab6ab344f61ccae9e683 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Mon, 2 Dec 2024 23:15:22 -0600 Subject: [PATCH 13/44] error handling --- .../Manage/DeleteAccountController.swift | 16 ++++++++++------ .../Manage/ForgotPasswordController.swift | 2 +- .../App/Controllers/Manage/LoginController.swift | 4 ++-- .../App/Controllers/Manage/ResetController.swift | 2 +- .../Controllers/Manage/SignupController.swift | 14 +++++++++++++- .../Controllers/Manage/VerifyController.swift | 2 +- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Sources/App/Controllers/Manage/DeleteAccountController.swift b/Sources/App/Controllers/Manage/DeleteAccountController.swift index a4e17a70d..228d1f3dd 100644 --- a/Sources/App/Controllers/Manage/DeleteAccountController.swift +++ b/Sources/App/Controllers/Manage/DeleteAccountController.swift @@ -9,11 +9,15 @@ import SotoCognitoIdentity enum DeleteAccountController { @Sendable static func deleteAccount(req: Request) async throws -> Response { - let request = try CognitoIdentityProvider.DeleteUserRequest(accessToken: req.auth.require(AuthenticatedUser.self).sessionID) - try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) - req.auth.logout(AuthenticatedUser.self) - req.session.unauthenticate(AuthenticatedUser.self) - req.session.destroy() - return req.redirect(to: SiteURL.home.relativeURL()) + do { + let request = try CognitoIdentityProvider.DeleteUserRequest(accessToken: req.auth.require(AuthenticatedUser.self).sessionID) + try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) + req.auth.logout(AuthenticatedUser.self) + req.session.unauthenticate(AuthenticatedUser.self) + req.session.destroy() + return req.redirect(to: SiteURL.home.relativeURL()) + } catch { + return Portal.View(path: SiteURL.portal.relativeURL(), model: Portal.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .internalServerError) + } } } diff --git a/Sources/App/Controllers/Manage/ForgotPasswordController.swift b/Sources/App/Controllers/Manage/ForgotPasswordController.swift index 7ce14a38d..8a46f27f5 100644 --- a/Sources/App/Controllers/Manage/ForgotPasswordController.swift +++ b/Sources/App/Controllers/Manage/ForgotPasswordController.swift @@ -21,7 +21,7 @@ enum ForgotPasswordController { try await req.application.cognito.authenticatable.forgotPassword(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: "There was an error. Please try again.")).document() + return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model(errorMessage: "An error occurred: \(error.localizedDescription)")).document() } } } diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index 5ff5e0e34..fff27ce2a 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -48,10 +48,10 @@ enum LoginController { return Login.View(path: req.url.path, model: model).document().encodeResponse(status: .unauthorized) } catch let error as AWSClientError { try await awsClient.shutdown() - return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An AWS client error occurred: \(error.errorCode)")).document().encodeResponse(status: .unauthorized) + return Login.View(path: SiteURL.login.relativeURL(), model: Login.Model(errorMessage: "An AWS client error occurred: \(error.errorCode)")).document().encodeResponse(status: .unauthorized) } catch { try await awsClient.shutdown() - return Login.View(path: SiteURL.signup.relativeURL(), model: Login.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .unauthorized) + 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/Manage/ResetController.swift b/Sources/App/Controllers/Manage/ResetController.swift index 00a1ecba7..5b63da227 100644 --- a/Sources/App/Controllers/Manage/ResetController.swift +++ b/Sources/App/Controllers/Manage/ResetController.swift @@ -27,7 +27,7 @@ enum ResetController { let model = Reset.Model(errorMessage: error.message ?? "There was an error.") return Reset.View(path: req.url.path, model: model).document() } catch { - let model = Reset.Model(errorMessage: "An unknown error occurred.") + 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/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift index d0a324060..053332fbf 100644 --- a/Sources/App/Controllers/Manage/SignupController.swift +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -15,6 +15,15 @@ enum SignupController { @Sendable static func signup(req: Request) async throws -> HTML { @Dependency(\.cognito) var cognito + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + let awsCognitoConfiguration = CognitoConfiguration( + userPoolId: Environment.get("POOL_ID")!, + clientId: Environment.get("CLIENT_ID")!, + clientSecret: Environment.get("CLIENT_SECRET")!, + cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), + adminClient: true + ) + req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) struct UserCreds: Content { var email: String var password: String @@ -22,11 +31,14 @@ enum SignupController { do { let user = try req.content.decode(UserCreds.self) try await cognito.signup(req: req, username: user.email, password: user.password) + try await awsClient.shutdown() return Verify.View(path: SiteURL.verify.relativeURL(), model: Verify.Model(email: user.email)).document() } catch let error as AWSErrorType { let model = Signup.Model(errorMessage: error.message ?? "There was an error.") + try await awsClient.shutdown() return Signup.View(path: req.url.path, model: model).document() - } catch { + } catch { + try await awsClient.shutdown() return Signup.View(path: SiteURL.signup.relativeURL(), model: Signup.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document() } diff --git a/Sources/App/Controllers/Manage/VerifyController.swift b/Sources/App/Controllers/Manage/VerifyController.swift index 3d1208fe0..36cb92a8b 100644 --- a/Sources/App/Controllers/Manage/VerifyController.swift +++ b/Sources/App/Controllers/Manage/VerifyController.swift @@ -27,7 +27,7 @@ enum VerifyController { let model = Verify.Model(email: info.email, errorMessage: error.message ?? "There was an error.") return Verify.View(path: req.url.path, model: model).document() } catch { - let model = Verify.Model(email: info.email, errorMessage: "An unknown error occurred.") + let model = Verify.Model(email: info.email, errorMessage: "An unknown error occurred: \(error.localizedDescription)") return Verify.View(path: req.url.path, model: model).document() } } From f06be9a8b27bea1f70c8ca5aa5026a3628dec301 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Mon, 2 Dec 2024 23:35:44 -0600 Subject: [PATCH 14/44] descriptive env variables + entirely move cognito config --- Sources/App/Controllers/Manage/Cognito.swift | 12 ++++++------ .../App/Controllers/Manage/LoginController.swift | 13 ------------- .../App/Controllers/Manage/SignupController.swift | 12 ------------ 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index 1854fb3d3..26feca3cb 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -8,9 +8,9 @@ struct Cognito { static func authenticate(req: Request, username: String, password: String) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) let awsCognitoConfiguration = CognitoConfiguration( - userPoolId: Environment.get("POOL_ID")!, - clientId: Environment.get("CLIENT_ID")!, - clientSecret: Environment.get("CLIENT_SECRET")!, + 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 ) @@ -30,9 +30,9 @@ struct Cognito { static func signup(req: Request, username: String, password: String) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) let awsCognitoConfiguration = CognitoConfiguration( - userPoolId: Environment.get("POOL_ID")!, - clientId: Environment.get("CLIENT_ID")!, - clientSecret: Environment.get("CLIENT_SECRET")!, + 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 ) diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index fff27ce2a..90dce53dc 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -16,15 +16,6 @@ enum LoginController { @Sendable static func login(req: Request) async throws -> Response { @Dependency(\.cognito) var cognito - let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - let awsCognitoConfiguration = CognitoConfiguration( - userPoolId: Environment.get("POOL_ID")!, - clientId: Environment.get("CLIENT_ID")!, - clientSecret: Environment.get("CLIENT_SECRET")!, - cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), - adminClient: true - ) - req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) struct UserCreds: Content { var email: String var password: String @@ -32,7 +23,6 @@ enum LoginController { do { let user = try req.content.decode(UserCreds.self) try await cognito.authenticate(req: req, username: user.email, password: user.password) - try await awsClient.shutdown() 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.") @@ -44,13 +34,10 @@ enum LoginController { case .invalidPublicKey: break } - try await awsClient.shutdown() return Login.View(path: req.url.path, model: model).document().encodeResponse(status: .unauthorized) } catch let error as AWSClientError { - try await awsClient.shutdown() return Login.View(path: SiteURL.login.relativeURL(), model: Login.Model(errorMessage: "An AWS client error occurred: \(error.errorCode)")).document().encodeResponse(status: .unauthorized) } catch { - try await awsClient.shutdown() 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/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift index 053332fbf..83aaefdab 100644 --- a/Sources/App/Controllers/Manage/SignupController.swift +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -15,15 +15,6 @@ enum SignupController { @Sendable static func signup(req: Request) async throws -> HTML { @Dependency(\.cognito) var cognito - let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - let awsCognitoConfiguration = CognitoConfiguration( - userPoolId: Environment.get("POOL_ID")!, - clientId: Environment.get("CLIENT_ID")!, - clientSecret: Environment.get("CLIENT_SECRET")!, - cognitoIDP: CognitoIdentityProvider(client: awsClient, region: .useast2), - adminClient: true - ) - req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) struct UserCreds: Content { var email: String var password: String @@ -31,14 +22,11 @@ enum SignupController { do { let user = try req.content.decode(UserCreds.self) try await cognito.signup(req: req, username: user.email, password: user.password) - try await awsClient.shutdown() return Verify.View(path: SiteURL.verify.relativeURL(), model: Verify.Model(email: user.email)).document() } catch let error as AWSErrorType { let model = Signup.Model(errorMessage: error.message ?? "There was an error.") - try await awsClient.shutdown() return Signup.View(path: req.url.path, model: model).document() } catch { - try await awsClient.shutdown() return Signup.View(path: SiteURL.signup.relativeURL(), model: Signup.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document() } From 21631c0c97d42eadccd05d47af070e97362f9ef1 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Mon, 2 Dec 2024 23:36:32 -0600 Subject: [PATCH 15/44] support displaying error message in portal view --- Sources/App/Views/Manage/Portal+View.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/App/Views/Manage/Portal+View.swift b/Sources/App/Views/Manage/Portal+View.swift index f5334fe9e..6f4f54697 100644 --- a/Sources/App/Views/Manage/Portal+View.swift +++ b/Sources/App/Views/Manage/Portal+View.swift @@ -3,8 +3,19 @@ import Foundation enum Portal { + 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" } @@ -13,7 +24,8 @@ enum Portal { .div( .h2("Portal"), .logoutButton(), - .deleteButton() + .deleteButton(), + .text(model.errorMessage) ) } } From 0700d20d2765e207a1dc04832c50bdb448a62ad5 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 31 Dec 2024 16:31:29 -0600 Subject: [PATCH 16/44] seperate more cognito functions --- Sources/App/Controllers/Manage/Cognito.swift | 55 +++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index 26feca3cb..2021309a8 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -5,7 +5,7 @@ import SotoCognitoIdentity struct Cognito { @Sendable - static func authenticate(req: Request, username: String, password: String) async throws { + static func authenticate(req: Request, username: String, password: String) async throws -> CognitoAuthenticateResponse { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) let awsCognitoConfiguration = CognitoConfiguration( userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, @@ -16,13 +16,22 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) let response = try await req.application.cognito.authenticatable.authenticate(username: username, password: password) - switch response { - case .authenticated(let authenticatedResponse): - let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!, refreshToken: authenticatedResponse.refreshToken!) - req.auth.login(user) - case .challenged(let challengedResponse): // TODO: handle challenge - break - } + try awsClient.syncShutdown() + return response + } + + @Sendable + static func authenticateToken(req: Request, sessionID: String, accessToken: String, on eventLoop: EventLoop) async throws -> Void { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + 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 awsClient.syncShutdown() } @@ -40,4 +49,34 @@ struct Cognito { try await req.application.cognito.authenticatable.signUp(username: username, password: password, attributes: [:], on:req.eventLoop) try awsClient.syncShutdown() } + + @Sendable + static func forgotPassword(req: Request, username: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + 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 awsClient.syncShutdown() + } + + @Sendable + static func resetPassword(req: Request, username: String, password: String, confirmationCode: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + 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 awsClient.syncShutdown() + } } From b6b2bc6770dfbb9d77ef6b8d451bd0c4c9a72fa2 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 31 Dec 2024 16:43:11 -0600 Subject: [PATCH 17/44] support separated cognito functionality in forgot, login, and reset --- .../Controllers/Manage/ForgotPasswordController.swift | 4 ++-- Sources/App/Controllers/Manage/LoginController.swift | 9 ++++++++- Sources/App/Controllers/Manage/ResetController.swift | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/App/Controllers/Manage/ForgotPasswordController.swift b/Sources/App/Controllers/Manage/ForgotPasswordController.swift index 8a46f27f5..cf7480fca 100644 --- a/Sources/App/Controllers/Manage/ForgotPasswordController.swift +++ b/Sources/App/Controllers/Manage/ForgotPasswordController.swift @@ -16,9 +16,9 @@ enum ForgotPasswordController { struct Credentials: Content { var email: String } - let user = try req.content.decode(Credentials.self) do { - try await req.application.cognito.authenticatable.forgotPassword(username: user.email) + 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/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index 90dce53dc..c95b1a77f 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -22,7 +22,14 @@ enum LoginController { } do { let user = try req.content.decode(UserCreds.self) - try await cognito.authenticate(req: req, username: user.email, password: user.password) + 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!, refreshToken: authenticatedResponse.refreshToken!) + req.auth.login(user) + case .challenged(let challengedResponse): // with the current pool configuration, a challenge response is not expected + 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.") diff --git a/Sources/App/Controllers/Manage/ResetController.swift b/Sources/App/Controllers/Manage/ResetController.swift index 5b63da227..ca164ca00 100644 --- a/Sources/App/Controllers/Manage/ResetController.swift +++ b/Sources/App/Controllers/Manage/ResetController.swift @@ -18,9 +18,9 @@ enum ResetController { var password: String var confirmationCode: String } - let user = try req.content.decode(UserInfo.self) do { - try await req.application.cognito.authenticatable.confirmForgotPassword(username: user.email, newPassword: user.password, confirmationCode: user.confirmationCode) + 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 { From 205ee2dc7fe94c770652d28de0c8f956bbdd74c0 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 1 Jan 2025 11:24:40 -0600 Subject: [PATCH 18/44] seperate verify and delete cognito functions --- Sources/App/Controllers/Manage/Cognito.swift | 30 +++++++++++++++++++ .../Manage/DeleteAccountController.swift | 3 +- .../Controllers/Manage/VerifyController.swift | 6 ++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index 2021309a8..85583f190 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -79,4 +79,34 @@ struct Cognito { try await req.application.cognito.authenticatable.confirmForgotPassword(username: username, newPassword: password, confirmationCode: confirmationCode) try awsClient.syncShutdown() } + + @Sendable + static func confirmSignUp(req: Request, username: String, confirmationCode: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + 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) + } + + @Sendable + static func deleteUser(req: Request, accessToken: String) async throws { + let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) + 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 = CognitoIdentityProvider.DeleteUserRequest(accessToken: accessToken) + try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) + try awsClient.syncShutdown() + } } diff --git a/Sources/App/Controllers/Manage/DeleteAccountController.swift b/Sources/App/Controllers/Manage/DeleteAccountController.swift index 228d1f3dd..3d3436830 100644 --- a/Sources/App/Controllers/Manage/DeleteAccountController.swift +++ b/Sources/App/Controllers/Manage/DeleteAccountController.swift @@ -10,8 +10,7 @@ enum DeleteAccountController { @Sendable static func deleteAccount(req: Request) async throws -> Response { do { - let request = try CognitoIdentityProvider.DeleteUserRequest(accessToken: req.auth.require(AuthenticatedUser.self).sessionID) - try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) + try await Cognito.deleteUser(req: req, accessToken: req.auth.require(AuthenticatedUser.self).sessionID) req.auth.logout(AuthenticatedUser.self) req.session.unauthenticate(AuthenticatedUser.self) req.session.destroy() diff --git a/Sources/App/Controllers/Manage/VerifyController.swift b/Sources/App/Controllers/Manage/VerifyController.swift index 36cb92a8b..8a058a648 100644 --- a/Sources/App/Controllers/Manage/VerifyController.swift +++ b/Sources/App/Controllers/Manage/VerifyController.swift @@ -18,15 +18,17 @@ enum VerifyController { var email: String var confirmationCode: String } - let info = try req.content.decode(VerifyInformation.self) do { - try await req.application.cognito.authenticatable.confirmSignUp(username: info.email, confirmationCode: info.confirmationCode) + 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 model = Verify.Model(email: info.email, errorMessage: error.message ?? "There was an error.") 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() } From 0f524e9c396df155bb7b0c6855fb3c6bf770cb9b Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Fri, 3 Jan 2025 21:05:26 -0600 Subject: [PATCH 19/44] add cognito functions as dependencies for testing --- Sources/App/Controllers/Manage/Cognito.swift | 1 + .../Manage/DeleteAccountController.swift | 4 +++- .../Manage/ForgotPasswordController.swift | 4 +++- .../App/Controllers/Manage/ResetController.swift | 7 +++++-- .../Manage/SessionAuthentication.swift | 6 ++++-- .../App/Controllers/Manage/VerifyController.swift | 7 +++++-- Sources/App/Core/Dependencies/CognitoClient.swift | 15 +++++++++++++-- 7 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index 85583f190..d13eb1397 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -92,6 +92,7 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) try await req.application.cognito.authenticatable.confirmSignUp(username: username, confirmationCode: confirmationCode) + try awsClient.syncShutdown() } @Sendable diff --git a/Sources/App/Controllers/Manage/DeleteAccountController.swift b/Sources/App/Controllers/Manage/DeleteAccountController.swift index 3d3436830..f8f766bad 100644 --- a/Sources/App/Controllers/Manage/DeleteAccountController.swift +++ b/Sources/App/Controllers/Manage/DeleteAccountController.swift @@ -1,4 +1,5 @@ import Foundation +import Dependencies import Fluent import Plot import Vapor @@ -9,8 +10,9 @@ import SotoCognitoIdentity enum DeleteAccountController { @Sendable static func deleteAccount(req: Request) async throws -> Response { + @Dependency(\.cognito) var cognito do { - try await Cognito.deleteUser(req: req, accessToken: req.auth.require(AuthenticatedUser.self).sessionID) + try await cognito.deleteUser(req: req, accessToken: req.auth.require(AuthenticatedUser.self).sessionID) req.auth.logout(AuthenticatedUser.self) req.session.unauthenticate(AuthenticatedUser.self) req.session.destroy() diff --git a/Sources/App/Controllers/Manage/ForgotPasswordController.swift b/Sources/App/Controllers/Manage/ForgotPasswordController.swift index cf7480fca..5f18fc69e 100644 --- a/Sources/App/Controllers/Manage/ForgotPasswordController.swift +++ b/Sources/App/Controllers/Manage/ForgotPasswordController.swift @@ -1,4 +1,5 @@ import Fluent +import Dependencies import Plot import Vapor import SotoCognitoAuthentication @@ -13,12 +14,13 @@ enum ForgotPasswordController { @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) + 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/Manage/ResetController.swift b/Sources/App/Controllers/Manage/ResetController.swift index ca164ca00..2cf14b67c 100644 --- a/Sources/App/Controllers/Manage/ResetController.swift +++ b/Sources/App/Controllers/Manage/ResetController.swift @@ -1,4 +1,5 @@ import Fluent +import Dependencies import Plot import Vapor import SotoCognitoAuthentication @@ -13,6 +14,7 @@ enum ResetController { @Sendable static func resetPassword(req: Request) async throws -> HTML { + @Dependency(\.cognito) var cognito struct UserInfo: Content { var email: String var password: String @@ -20,11 +22,12 @@ enum ResetController { } do { let user = try req.content.decode(UserInfo.self) - try await Cognito.resetPassword(req: req, username: user.email, password: user.password, confirmationCode: user.confirmationCode) + 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 model = Reset.Model(errorMessage: error.message ?? "There was an error.") + 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)") diff --git a/Sources/App/Controllers/Manage/SessionAuthentication.swift b/Sources/App/Controllers/Manage/SessionAuthentication.swift index bfadfa107..cce7fdce6 100644 --- a/Sources/App/Controllers/Manage/SessionAuthentication.swift +++ b/Sources/App/Controllers/Manage/SessionAuthentication.swift @@ -1,4 +1,5 @@ import Vapor +import Dependencies import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity @@ -16,11 +17,12 @@ extension AuthenticatedUser: SessionAuthenticatable { struct UserSessionAuthenticator: AsyncSessionAuthenticator { func authenticate(sessionID: String, for request: Vapor.Request) async throws { + @Dependency(\.cognito) var cognito do { // TODO: handle response, refresh token - let response = try await request.application.cognito.authenticatable.authenticate(accessToken: sessionID, on: request.eventLoop) + try await cognito.authenticateToken(req: request, sessionID: sessionID, accessToken: sessionID, eventLoop: request.eventLoop) request.auth.login(User(accessToken: sessionID)) - } catch let error as SotoCognitoError { // TODO: handle error + } catch let error as SotoCognitoError { // TODO: handle error return } } diff --git a/Sources/App/Controllers/Manage/VerifyController.swift b/Sources/App/Controllers/Manage/VerifyController.swift index 8a058a648..8eeca6ca4 100644 --- a/Sources/App/Controllers/Manage/VerifyController.swift +++ b/Sources/App/Controllers/Manage/VerifyController.swift @@ -5,6 +5,7 @@ import Vapor import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity +import Dependencies enum VerifyController { @Sendable @@ -14,18 +15,20 @@ enum VerifyController { @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) + 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 model = Verify.Model(email: info.email, errorMessage: error.message ?? "There was an error.") + 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) diff --git a/Sources/App/Core/Dependencies/CognitoClient.swift b/Sources/App/Core/Dependencies/CognitoClient.swift index 11457ec8d..11302106a 100644 --- a/Sources/App/Core/Dependencies/CognitoClient.swift +++ b/Sources/App/Core/Dependencies/CognitoClient.swift @@ -15,18 +15,29 @@ import Dependencies import DependenciesMacros import Vapor +import SotoCognitoAuthenticationKit @DependencyClient struct CognitoClient { - var authenticate: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void + var authenticate: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse + var authenticateToken: @Sendable (_ req: Request, _ sessionID: String, _ accessToken: String, _ eventLoop: EventLoop) 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, _ accessToken: String) 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) }, - signup : { req, username, password in try await Cognito.signup(req: req, username: username, password: password) } + authenticateToken: { req, sessionID, accessToken, eventLoop in try await Cognito.authenticateToken(req: req, sessionID: sessionID, accessToken: accessToken, on: eventLoop)}, + 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, accessToken in try await Cognito.deleteUser(req: req, accessToken: accessToken) } ) } } From 1f0a0d7b41477c77cabffe861f895cf20e828965 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sun, 5 Jan 2025 12:43:42 -0600 Subject: [PATCH 20/44] test suite for authentication --- Sources/App/Controllers/Manage/Cognito.swift | 4 +- .../Manage/DeleteAccountController.swift | 2 +- .../Controllers/Manage/PortalController.swift | 2 +- .../Controllers/Manage/SignupController.swift | 3 +- .../App/Core/Dependencies/CognitoClient.swift | 4 +- Tests/AppTests/ManageTests.swift | 276 +++++++++++++++++- 6 files changed, 278 insertions(+), 13 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index d13eb1397..1cb0317ed 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -96,7 +96,7 @@ struct Cognito { } @Sendable - static func deleteUser(req: Request, accessToken: String) async throws { + static func deleteUser(req: Request) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) let awsCognitoConfiguration = CognitoConfiguration( userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, @@ -106,7 +106,7 @@ struct Cognito { adminClient: true ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) - let request = CognitoIdentityProvider.DeleteUserRequest(accessToken: accessToken) + let request = try CognitoIdentityProvider.DeleteUserRequest(accessToken: req.auth.require(AuthenticatedUser.self).sessionID) try await req.application.cognito.authenticatable.configuration.cognitoIDP.deleteUser(request) try awsClient.syncShutdown() } diff --git a/Sources/App/Controllers/Manage/DeleteAccountController.swift b/Sources/App/Controllers/Manage/DeleteAccountController.swift index f8f766bad..8317cc48e 100644 --- a/Sources/App/Controllers/Manage/DeleteAccountController.swift +++ b/Sources/App/Controllers/Manage/DeleteAccountController.swift @@ -12,7 +12,7 @@ enum DeleteAccountController { static func deleteAccount(req: Request) async throws -> Response { @Dependency(\.cognito) var cognito do { - try await cognito.deleteUser(req: req, accessToken: req.auth.require(AuthenticatedUser.self).sessionID) + try await cognito.deleteUser(req: req) req.auth.logout(AuthenticatedUser.self) req.session.unauthenticate(AuthenticatedUser.self) req.session.destroy() diff --git a/Sources/App/Controllers/Manage/PortalController.swift b/Sources/App/Controllers/Manage/PortalController.swift index 7560e4b04..9f9b9bc67 100644 --- a/Sources/App/Controllers/Manage/PortalController.swift +++ b/Sources/App/Controllers/Manage/PortalController.swift @@ -6,6 +6,6 @@ import SotoCognitoAuthenticationKit enum PortalController { @Sendable static func show(req: Request) async throws -> HTML { - return Portal.View(path: req.url.path).document() + return Portal.View(path: req.url.path, model: Portal.Model()).document() } } diff --git a/Sources/App/Controllers/Manage/SignupController.swift b/Sources/App/Controllers/Manage/SignupController.swift index 83aaefdab..5ba1e564a 100644 --- a/Sources/App/Controllers/Manage/SignupController.swift +++ b/Sources/App/Controllers/Manage/SignupController.swift @@ -24,7 +24,8 @@ enum SignupController { 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 model = Signup.Model(errorMessage: error.message ?? "There was an error.") + 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/Core/Dependencies/CognitoClient.swift b/Sources/App/Core/Dependencies/CognitoClient.swift index 11302106a..f0f5f5888 100644 --- a/Sources/App/Core/Dependencies/CognitoClient.swift +++ b/Sources/App/Core/Dependencies/CognitoClient.swift @@ -25,7 +25,7 @@ struct CognitoClient { 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, _ accessToken: String) async throws -> Void + var deleteUser: @Sendable (_ req: Request) async throws -> Void } extension CognitoClient: DependencyKey { @@ -37,7 +37,7 @@ extension CognitoClient: DependencyKey { 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, accessToken in try await Cognito.deleteUser(req: req, accessToken: accessToken) } + deleteUser: { req in try await Cognito.deleteUser(req: req) } ) } } diff --git a/Tests/AppTests/ManageTests.swift b/Tests/AppTests/ManageTests.swift index 511060a15..6ae61e5b9 100644 --- a/Tests/AppTests/ManageTests.swift +++ b/Tests/AppTests/ManageTests.swift @@ -18,6 +18,9 @@ import XCTest import Fluent import Vapor import Dependencies +import SotoCognitoAuthenticationKit + + class ManageTests: AppTestCase { @@ -25,31 +28,292 @@ class ManageTests: AppTestCase { func test_portal_route_protected() throws { try app.test(.GET, "portal") { res in XCTAssertEqual(res.status, .seeOther) + if let location = res.headers.first(name: .location) { + XCTAssertEqual("/login", location) + } } } func test_login_successful_redirect() throws { - // not throwing in auth is a successful authentication and user - // should be redirected try withDependencies { - var mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in } + 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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) }, afterResponse: { res in XCTAssertEqual(res.status, .seeOther) + if let location = res.headers.first(name: .location) { + XCTAssertEqual("/portal", location) + } + }) + } + } + + func test_successful_login_secure_cookie_set() throws { + try 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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + if let cookieHeader = res.headers.first(name: .setCookie) { + XCTAssertTrue(cookieHeader.contains("HttpOnly")) + XCTAssertTrue(cookieHeader.contains("Secure")) + } }) } } - func test_login_throws() throws { + func test_login_soto_error() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in throw SotoCognitoError.unauthorized(reason: "reason") } + $0.cognito.authenticate = mock + } operation: { + try app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .unauthorized) + }) + } + } + + func test_login_some_aws_client_error() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in throw AWSClientError.accessDenied } + $0.cognito.authenticate = mock + } operation: { + try app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .unauthorized) + }) + } + } + + func test_login_throw_other_error() throws { struct SomeError: Error {} try withDependencies { - var mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in throw SomeError() } + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in throw SomeError() } $0.cognito.authenticate = mock } operation: { - try app.test(.POST, "login") { res in + try app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in XCTAssertEqual(res.status, .unauthorized) + }) + } + } + + func test_signup_successful_view_change() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in } + $0.cognito.signup = mock + } operation: { + try app.test(.POST, "signup", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .ok) + XCTAssertTrue(res.body.string.contains("Verify")) + }) + } + } + + func test_signup_some_aws_error() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in throw AWSClientError.accessDenied } + $0.cognito.signup = mock + } operation: { + try app.test(.POST, "signup", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) + }, afterResponse: { res in + XCTAssertTrue(res.body.string.contains("There was an error")) + }) + } + } + + func test_signup_throw_some_error() throws { + struct SomeError: Error {} + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in throw SomeError() } + $0.cognito.signup = mock + } operation: { + try app.test(.POST, "signup") { res in + XCTAssertTrue(res.body.string.contains("error")) + } + } + } + + func test_reset_password_successful_view_change() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void = { _, _, _, _ in } + $0.cognito.resetPassword = mock + } operation: { + try app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .ok) + XCTAssertTrue(res.body.string.contains("Successfully changed password")) + }) + } + } + + func test_reset_pass_throws_aws_error() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void = { _, _, _, _ in throw AWSClientError.accessDenied } + $0.cognito.resetPassword = mock + } operation: { + try app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + }, afterResponse: { res in + XCTAssertTrue(res.body.string.contains("There was an error")) + }) + } + } + + func test_reset_pass_throws_other_error() throws { + try 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 + } operation: { + try app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + }, afterResponse: { res in + XCTAssertTrue(res.body.string.contains("An unknown error occurred")) + }) + } + } + + func test_forgot_pass_successful_view_change() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String) async throws -> Void = { _, _ in } + $0.cognito.forgotPassword = mock + } operation: { + try app.test(.POST, "forgot-password", beforeRequest: { req in try req.content.encode(["email": "testemail"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .ok) + XCTAssertTrue(res.body.string.contains("Reset Password")) + }) + } + } + + func test_forgot_pass_throws() throws { + try withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request, _ username: String) async throws -> Void = { _, _ in throw SomeError() } + $0.cognito.forgotPassword = mock + } operation: { + try app.test(.POST, "forgot-password", beforeRequest: { req in try req.content.encode(["email": "testemail"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .ok) + XCTAssertTrue(res.body.string.contains("An error occurred")) + }) + } + } + + func test_logout_successful_redirect() throws { + try app.test(.POST, "logout") { res in + XCTAssertEqual(res.status, .seeOther) + if let location = res.headers.first(name: .location) { + XCTAssertEqual("/", location) + } + } + } + + func test_logout_session_destroyed() throws { + try app.test(.POST, "logout") { res in + let cookie = res.headers.setCookie?["vapor-session"] + XCTAssertNil(cookie) + } + } + + func test_verify_successful_view_Change() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in } + $0.cognito.confirmSignUp = mock + } operation: { + try app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) + }, afterResponse: { res in + XCTAssertEqual(res.status, .ok) + XCTAssertTrue(res.body.string.contains("Successfully confirmed signup")) + }) + } + } + + func test_verify_throws_aws_error() throws { + try withDependencies { + let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in throw AWSClientError.accessDenied } + $0.cognito.confirmSignUp = mock + } operation: { + try app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) + }, afterResponse: { res in + XCTAssertTrue(res.body.string.contains("There was an error")) + }) + } + } + + func test_verify_throws_some_error() throws { + try withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in throw SomeError() } + $0.cognito.confirmSignUp = mock + } operation: { + try app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) + }, afterResponse: { res in + XCTAssertTrue(res.body.string.contains("An unknown error occurred")) + }) + } + } + + func test_delete_successful_redirect() throws { + try withDependencies { + let mock: @Sendable (_ req: Request) async throws -> Void = { _ in } + $0.cognito.deleteUser = mock + } operation: { + try app.test(.POST, "delete") { res in + XCTAssertEqual(res.status, .seeOther) + if let location = res.headers.first(name: .location) { + XCTAssertEqual("/", location) + } + } + } + } + + func test_delete_session_destroyed() throws { + try withDependencies { + let mock: @Sendable (_ req: Request) async throws -> Void = { _ in } + $0.cognito.deleteUser = mock + } operation: { + try app.test(.POST, "delete") { res in + let cookie = res.headers.setCookie?["vapor-session"] + XCTAssertNil(cookie) + } + } + } + + func test_delete_throws() throws { + try withDependencies { + struct SomeError: Error {} + let mock: @Sendable (_ req: Request) async throws -> Void = { _ in throw SomeError() } + $0.cognito.deleteUser = mock + } operation: { + try app.test(.POST, "delete") { res in + XCTAssertEqual(res.status, .internalServerError) + XCTAssertTrue(res.body.string.contains("An unknown error occurred")) } } } From 79339fac0a5c4c44343eb8516fd209c7cac419bf Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sun, 19 Jan 2025 22:54:44 -0600 Subject: [PATCH 21/44] simplification of authenticateToken + address refresh --- Sources/App/Controllers/Manage/Cognito.swift | 2 +- Sources/App/Controllers/Manage/LoginController.swift | 2 +- .../App/Controllers/Manage/SessionAuthentication.swift | 8 +++----- Sources/App/Core/Dependencies/CognitoClient.swift | 4 ++-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index 1cb0317ed..f0012b76c 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -21,7 +21,7 @@ struct Cognito { } @Sendable - static func authenticateToken(req: Request, sessionID: String, accessToken: String, on eventLoop: EventLoop) async throws -> Void { + static func authenticateToken(req: Request, sessionID: String, accessToken: String) async throws -> Void { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) let awsCognitoConfiguration = CognitoConfiguration( userPoolId: Environment.get("AWS_COGNITO_POOL_ID")!, diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index c95b1a77f..815f4805a 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -25,7 +25,7 @@ enum LoginController { 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!, refreshToken: authenticatedResponse.refreshToken!) + let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!) req.auth.login(user) case .challenged(let challengedResponse): // with the current pool configuration, a challenge response is not expected break diff --git a/Sources/App/Controllers/Manage/SessionAuthentication.swift b/Sources/App/Controllers/Manage/SessionAuthentication.swift index cce7fdce6..9c79f2109 100644 --- a/Sources/App/Controllers/Manage/SessionAuthentication.swift +++ b/Sources/App/Controllers/Manage/SessionAuthentication.swift @@ -6,7 +6,6 @@ import SotoCognitoIdentity struct AuthenticatedUser { var accessToken: String - var refreshToken: String? } extension AuthenticatedUser: SessionAuthenticatable { @@ -19,11 +18,10 @@ struct UserSessionAuthenticator: AsyncSessionAuthenticator { func authenticate(sessionID: String, for request: Vapor.Request) async throws { @Dependency(\.cognito) var cognito do { - // TODO: handle response, refresh token - try await cognito.authenticateToken(req: request, sessionID: sessionID, accessToken: sessionID, eventLoop: request.eventLoop) + try await cognito.authenticateToken(req: request, sessionID: sessionID, accessToken: sessionID) request.auth.login(User(accessToken: sessionID)) - } catch let error as SotoCognitoError { // TODO: handle error - return + } catch let error as SotoCognitoError { + // .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/Dependencies/CognitoClient.swift b/Sources/App/Core/Dependencies/CognitoClient.swift index f0f5f5888..17cff351a 100644 --- a/Sources/App/Core/Dependencies/CognitoClient.swift +++ b/Sources/App/Core/Dependencies/CognitoClient.swift @@ -20,7 +20,7 @@ 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, _ eventLoop: EventLoop) async throws -> Void + 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 @@ -32,7 +32,7 @@ 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, eventLoop in try await Cognito.authenticateToken(req: req, sessionID: sessionID, accessToken: accessToken, on: eventLoop)}, + 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) }, From 4771e08cb358f13596eea56f52ce8a8104611832 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 5 Feb 2025 14:05:24 +0000 Subject: [PATCH 22/44] Fixed up SiteURL after merge. --- Sources/App/Core/SiteURL.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/App/Core/SiteURL.swift b/Sources/App/Core/SiteURL.swift index 988200fe8..5bd7a08a7 100644 --- a/Sources/App/Core/SiteURL.swift +++ b/Sources/App/Core/SiteURL.swift @@ -331,9 +331,8 @@ enum SiteURL: Resourceable, Sendable { .supporters, .tryInPlayground, .validateSPIManifest, - .verify: - .healthCheck, - .validateSPIManifest: + .verify, + .healthCheck: return [.init(stringLiteral: path)] case let .api(next): From d626dac23c740f09067f51b97e505c983fb9c2a1 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 5 Feb 2025 14:21:00 +0000 Subject: [PATCH 23/44] Fix a warning around the unimplemented refresh token exception. --- Sources/App/Controllers/Manage/SessionAuthentication.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/App/Controllers/Manage/SessionAuthentication.swift b/Sources/App/Controllers/Manage/SessionAuthentication.swift index 9c79f2109..b1f19235a 100644 --- a/Sources/App/Controllers/Manage/SessionAuthentication.swift +++ b/Sources/App/Controllers/Manage/SessionAuthentication.swift @@ -20,8 +20,10 @@ struct UserSessionAuthenticator: AsyncSessionAuthenticator { do { try await cognito.authenticateToken(req: request, sessionID: sessionID, accessToken: sessionID) request.auth.login(User(accessToken: sessionID)) - } catch let error as SotoCognitoError { - // .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 + } 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 From ce91401f0a786abf9a8530cd960bfb7bdb23b67f Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 5 Feb 2025 14:26:38 +0000 Subject: [PATCH 24/44] Merge inconsistency. --- Sources/App/Core/SiteURL.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/App/Core/SiteURL.swift b/Sources/App/Core/SiteURL.swift index 5bd7a08a7..fdb6ddcf7 100644 --- a/Sources/App/Core/SiteURL.swift +++ b/Sources/App/Core/SiteURL.swift @@ -256,9 +256,6 @@ enum SiteURL: Resourceable, Sendable { case .portal: return "portal" - case .portal: - return "portal" - case .privacy: return "privacy" From d19763e83e31acde7df6a9fa4df6989425156fe1 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 5 Feb 2025 14:26:53 +0000 Subject: [PATCH 25/44] Remove a couple of warrnings and a better comment. --- Sources/App/Controllers/Manage/Cognito.swift | 2 +- Sources/App/Controllers/Manage/LoginController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Manage/Cognito.swift index f0012b76c..efa7287b4 100644 --- a/Sources/App/Controllers/Manage/Cognito.swift +++ b/Sources/App/Controllers/Manage/Cognito.swift @@ -46,7 +46,7 @@ struct Cognito { 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 req.application.cognito.authenticatable.signUp(username: username, password: password, attributes: [:], on:req.eventLoop) try awsClient.syncShutdown() } diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index 815f4805a..275950605 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -27,7 +27,7 @@ enum LoginController { case .authenticated(let authenticatedResponse): let user = AuthenticatedUser(accessToken: authenticatedResponse.accessToken!) req.auth.login(user) - case .challenged(let challengedResponse): // with the current pool configuration, a challenge response is not expected + 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) From ac5231dee6fba6d89edfd8bbad9a3c08d5ab8dba Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Wed, 5 Feb 2025 14:29:45 +0000 Subject: [PATCH 26/44] Indentation to match project standards. --- .../Controllers/Manage/LoginController.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Manage/LoginController.swift index 275950605..2e58c8356 100644 --- a/Sources/App/Controllers/Manage/LoginController.swift +++ b/Sources/App/Controllers/Manage/LoginController.swift @@ -24,22 +24,22 @@ enum LoginController { 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 + 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 + 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 { From 82f6b93a1c5cb1ebf9c92b49adfc40213c4da525 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sat, 8 Feb 2025 21:49:23 -0600 Subject: [PATCH 27/44] rename manage to portal --- Sources/App/Controllers/{Manage => Portal}/Cognito.swift | 0 .../{Manage => Portal}/DeleteAccountController.swift | 0 .../{Manage => Portal}/ForgotPasswordController.swift | 0 .../App/Controllers/{Manage => Portal}/LoginController.swift | 0 .../App/Controllers/{Manage => Portal}/LogoutController.swift | 0 .../App/Controllers/{Manage => Portal}/PortalController.swift | 0 .../App/Controllers/{Manage => Portal}/ResetController.swift | 0 .../Controllers/{Manage => Portal}/SessionAuthentication.swift | 0 .../App/Controllers/{Manage => Portal}/SignupController.swift | 0 .../App/Controllers/{Manage => Portal}/VerifyController.swift | 0 Tests/AppTests/{ManageTests.swift => PortalTests.swift} | 2 +- 11 files changed, 1 insertion(+), 1 deletion(-) rename Sources/App/Controllers/{Manage => Portal}/Cognito.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/DeleteAccountController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/ForgotPasswordController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/LoginController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/LogoutController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/PortalController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/ResetController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/SessionAuthentication.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/SignupController.swift (100%) rename Sources/App/Controllers/{Manage => Portal}/VerifyController.swift (100%) rename Tests/AppTests/{ManageTests.swift => PortalTests.swift} (99%) diff --git a/Sources/App/Controllers/Manage/Cognito.swift b/Sources/App/Controllers/Portal/Cognito.swift similarity index 100% rename from Sources/App/Controllers/Manage/Cognito.swift rename to Sources/App/Controllers/Portal/Cognito.swift diff --git a/Sources/App/Controllers/Manage/DeleteAccountController.swift b/Sources/App/Controllers/Portal/DeleteAccountController.swift similarity index 100% rename from Sources/App/Controllers/Manage/DeleteAccountController.swift rename to Sources/App/Controllers/Portal/DeleteAccountController.swift diff --git a/Sources/App/Controllers/Manage/ForgotPasswordController.swift b/Sources/App/Controllers/Portal/ForgotPasswordController.swift similarity index 100% rename from Sources/App/Controllers/Manage/ForgotPasswordController.swift rename to Sources/App/Controllers/Portal/ForgotPasswordController.swift diff --git a/Sources/App/Controllers/Manage/LoginController.swift b/Sources/App/Controllers/Portal/LoginController.swift similarity index 100% rename from Sources/App/Controllers/Manage/LoginController.swift rename to Sources/App/Controllers/Portal/LoginController.swift diff --git a/Sources/App/Controllers/Manage/LogoutController.swift b/Sources/App/Controllers/Portal/LogoutController.swift similarity index 100% rename from Sources/App/Controllers/Manage/LogoutController.swift rename to Sources/App/Controllers/Portal/LogoutController.swift diff --git a/Sources/App/Controllers/Manage/PortalController.swift b/Sources/App/Controllers/Portal/PortalController.swift similarity index 100% rename from Sources/App/Controllers/Manage/PortalController.swift rename to Sources/App/Controllers/Portal/PortalController.swift diff --git a/Sources/App/Controllers/Manage/ResetController.swift b/Sources/App/Controllers/Portal/ResetController.swift similarity index 100% rename from Sources/App/Controllers/Manage/ResetController.swift rename to Sources/App/Controllers/Portal/ResetController.swift diff --git a/Sources/App/Controllers/Manage/SessionAuthentication.swift b/Sources/App/Controllers/Portal/SessionAuthentication.swift similarity index 100% rename from Sources/App/Controllers/Manage/SessionAuthentication.swift rename to Sources/App/Controllers/Portal/SessionAuthentication.swift diff --git a/Sources/App/Controllers/Manage/SignupController.swift b/Sources/App/Controllers/Portal/SignupController.swift similarity index 100% rename from Sources/App/Controllers/Manage/SignupController.swift rename to Sources/App/Controllers/Portal/SignupController.swift diff --git a/Sources/App/Controllers/Manage/VerifyController.swift b/Sources/App/Controllers/Portal/VerifyController.swift similarity index 100% rename from Sources/App/Controllers/Manage/VerifyController.swift rename to Sources/App/Controllers/Portal/VerifyController.swift diff --git a/Tests/AppTests/ManageTests.swift b/Tests/AppTests/PortalTests.swift similarity index 99% rename from Tests/AppTests/ManageTests.swift rename to Tests/AppTests/PortalTests.swift index 6ae61e5b9..57f28300a 100644 --- a/Tests/AppTests/ManageTests.swift +++ b/Tests/AppTests/PortalTests.swift @@ -23,7 +23,7 @@ import SotoCognitoAuthenticationKit -class ManageTests: AppTestCase { +class PortalTests: AppTestCase { func test_portal_route_protected() throws { try app.test(.GET, "portal") { res in From 0ddb07fc89ca64736849afee5e4df6825d8fbbd1 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 11 Feb 2025 12:35:20 -0600 Subject: [PATCH 28/44] renaming --- Sources/App/Controllers/Portal/DeleteAccountController.swift | 2 +- Sources/App/Controllers/Portal/PortalController.swift | 2 +- Sources/App/Controllers/Portal/SessionAuthentication.swift | 4 +--- .../App/Views/{Manage => Portal}/ForgotPassword+View.swift | 0 Sources/App/Views/{Manage => Portal}/Login+View.swift | 0 Sources/App/Views/{Manage => Portal}/Portal+View.swift | 2 +- Sources/App/Views/{Manage => Portal}/Reset+View.swift | 0 Sources/App/Views/{Manage => Portal}/Signup+View.swift | 0 .../Views/{Manage => Portal}/Successful+Password+Change.swift | 0 Sources/App/Views/{Manage => Portal}/Verify+View.swift | 0 10 files changed, 4 insertions(+), 6 deletions(-) rename Sources/App/Views/{Manage => Portal}/ForgotPassword+View.swift (100%) rename Sources/App/Views/{Manage => Portal}/Login+View.swift (100%) rename Sources/App/Views/{Manage => Portal}/Portal+View.swift (98%) rename Sources/App/Views/{Manage => Portal}/Reset+View.swift (100%) rename Sources/App/Views/{Manage => Portal}/Signup+View.swift (100%) rename Sources/App/Views/{Manage => Portal}/Successful+Password+Change.swift (100%) rename Sources/App/Views/{Manage => Portal}/Verify+View.swift (100%) diff --git a/Sources/App/Controllers/Portal/DeleteAccountController.swift b/Sources/App/Controllers/Portal/DeleteAccountController.swift index 8317cc48e..0767662e9 100644 --- a/Sources/App/Controllers/Portal/DeleteAccountController.swift +++ b/Sources/App/Controllers/Portal/DeleteAccountController.swift @@ -18,7 +18,7 @@ enum DeleteAccountController { req.session.destroy() return req.redirect(to: SiteURL.home.relativeURL()) } catch { - return Portal.View(path: SiteURL.portal.relativeURL(), model: Portal.Model(errorMessage: "An unknown error occurred: \(error.localizedDescription)")).document().encodeResponse(status: .internalServerError) + 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/PortalController.swift b/Sources/App/Controllers/Portal/PortalController.swift index 9f9b9bc67..ed4411cb7 100644 --- a/Sources/App/Controllers/Portal/PortalController.swift +++ b/Sources/App/Controllers/Portal/PortalController.swift @@ -6,6 +6,6 @@ import SotoCognitoAuthenticationKit enum PortalController { @Sendable static func show(req: Request) async throws -> HTML { - return Portal.View(path: req.url.path, model: Portal.Model()).document() + return PortalPage.View(path: req.url.path, model: PortalPage.Model()).document() } } diff --git a/Sources/App/Controllers/Portal/SessionAuthentication.swift b/Sources/App/Controllers/Portal/SessionAuthentication.swift index b1f19235a..32a67af4e 100644 --- a/Sources/App/Controllers/Portal/SessionAuthentication.swift +++ b/Sources/App/Controllers/Portal/SessionAuthentication.swift @@ -21,9 +21,7 @@ struct UserSessionAuthenticator: AsyncSessionAuthenticator { 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. + // 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/Views/Manage/ForgotPassword+View.swift b/Sources/App/Views/Portal/ForgotPassword+View.swift similarity index 100% rename from Sources/App/Views/Manage/ForgotPassword+View.swift rename to Sources/App/Views/Portal/ForgotPassword+View.swift diff --git a/Sources/App/Views/Manage/Login+View.swift b/Sources/App/Views/Portal/Login+View.swift similarity index 100% rename from Sources/App/Views/Manage/Login+View.swift rename to Sources/App/Views/Portal/Login+View.swift diff --git a/Sources/App/Views/Manage/Portal+View.swift b/Sources/App/Views/Portal/Portal+View.swift similarity index 98% rename from Sources/App/Views/Manage/Portal+View.swift rename to Sources/App/Views/Portal/Portal+View.swift index 6f4f54697..6fee2a95e 100644 --- a/Sources/App/Views/Manage/Portal+View.swift +++ b/Sources/App/Views/Portal/Portal+View.swift @@ -1,7 +1,7 @@ import Plot import Foundation -enum Portal { +enum PortalPage { struct Model { var errorMessage: String = "" diff --git a/Sources/App/Views/Manage/Reset+View.swift b/Sources/App/Views/Portal/Reset+View.swift similarity index 100% rename from Sources/App/Views/Manage/Reset+View.swift rename to Sources/App/Views/Portal/Reset+View.swift diff --git a/Sources/App/Views/Manage/Signup+View.swift b/Sources/App/Views/Portal/Signup+View.swift similarity index 100% rename from Sources/App/Views/Manage/Signup+View.swift rename to Sources/App/Views/Portal/Signup+View.swift diff --git a/Sources/App/Views/Manage/Successful+Password+Change.swift b/Sources/App/Views/Portal/Successful+Password+Change.swift similarity index 100% rename from Sources/App/Views/Manage/Successful+Password+Change.swift rename to Sources/App/Views/Portal/Successful+Password+Change.swift diff --git a/Sources/App/Views/Manage/Verify+View.swift b/Sources/App/Views/Portal/Verify+View.swift similarity index 100% rename from Sources/App/Views/Manage/Verify+View.swift rename to Sources/App/Views/Portal/Verify+View.swift From cb8ce7d43b712992e4d1c53948d95bf50732acea Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 11 Feb 2025 13:32:35 -0600 Subject: [PATCH 29/44] reduce scope of controllers under portal --- .../Portal/DeleteAccountController.swift | 27 +++++---- .../Portal/ForgotPasswordController.swift | 35 ++++++------ .../Controllers/Portal/LoginController.swift | 55 ++++++++++--------- .../Controllers/Portal/LogoutController.swift | 17 +++--- .../Controllers/Portal/PortalController.swift | 11 ++-- .../Controllers/Portal/ResetController.swift | 51 +++++++++-------- .../Controllers/Portal/SignupController.swift | 48 ++++++++-------- .../Controllers/Portal/VerifyController.swift | 53 +++++++++--------- .../Portal => Core}/Cognito.swift | 1 + .../SessionAuthentication.swift | 0 Sources/App/routes.swift | 26 ++++----- 11 files changed, 174 insertions(+), 150 deletions(-) rename Sources/App/{Controllers/Portal => Core}/Cognito.swift (99%) rename Sources/App/{Controllers/Portal => Core}/SessionAuthentication.swift (100%) diff --git a/Sources/App/Controllers/Portal/DeleteAccountController.swift b/Sources/App/Controllers/Portal/DeleteAccountController.swift index 0767662e9..54f179f79 100644 --- a/Sources/App/Controllers/Portal/DeleteAccountController.swift +++ b/Sources/App/Controllers/Portal/DeleteAccountController.swift @@ -7,18 +7,21 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity -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) +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 index 5f18fc69e..11b1f32f9 100644 --- a/Sources/App/Controllers/Portal/ForgotPasswordController.swift +++ b/Sources/App/Controllers/Portal/ForgotPasswordController.swift @@ -6,24 +6,27 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity -enum ForgotPasswordController { - @Sendable - static func show(req: Request) async throws -> HTML { - return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model()).document() - } +extension Portal { - @Sendable - static func forgotPasswordEmail(req: Request) async throws -> HTML { - @Dependency(\.cognito) var cognito - struct Credentials: Content { - var email: String + enum ForgotPasswordController { + @Sendable + static func show(req: Request) async throws -> HTML { + return ForgotPassword.View(path: req.url.path, model: ForgotPassword.Model()).document() } - 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() + + @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 index 2e58c8356..cf8275086 100644 --- a/Sources/App/Controllers/Portal/LoginController.swift +++ b/Sources/App/Controllers/Portal/LoginController.swift @@ -7,47 +7,50 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity -enum LoginController { - @Sendable - static func show(req: Request) async throws -> HTML { - return Login.View(path: req.url.path, model: Login.Model(errorMessage: "")).document() - } +enum Portal { - @Sendable - static func login(req: Request) async throws -> Response { - @Dependency(\.cognito) var cognito - struct UserCreds: Content { - var email: String - var password: String + enum LoginController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Login.View(path: req.url.path, model: Login.Model(errorMessage: "")).document() } - 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 { + + @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 { + } + 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) } - 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 index 2ba2cb4db..10ddd56fe 100644 --- a/Sources/App/Controllers/Portal/LogoutController.swift +++ b/Sources/App/Controllers/Portal/LogoutController.swift @@ -6,13 +6,16 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity -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()) +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 index ed4411cb7..109f1db99 100644 --- a/Sources/App/Controllers/Portal/PortalController.swift +++ b/Sources/App/Controllers/Portal/PortalController.swift @@ -3,9 +3,12 @@ import Plot import Vapor import SotoCognitoAuthenticationKit -enum PortalController { - @Sendable - static func show(req: Request) async throws -> HTML { - return PortalPage.View(path: req.url.path, model: PortalPage.Model()).document() +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 index 2cf14b67c..d44912da8 100644 --- a/Sources/App/Controllers/Portal/ResetController.swift +++ b/Sources/App/Controllers/Portal/ResetController.swift @@ -6,32 +6,35 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity -enum ResetController { - @Sendable - static func show(req: Request) async throws -> HTML { - return Reset.View(path: req.url.path, model: Reset.Model()).document() - } +extension Portal { - @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 + enum ResetController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Reset.View(path: req.url.path, model: Reset.Model()).document() } - 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() + + @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 index 5ba1e564a..4c4a4d390 100644 --- a/Sources/App/Controllers/Portal/SignupController.swift +++ b/Sources/App/Controllers/Portal/SignupController.swift @@ -6,31 +6,33 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity -enum SignupController { - @Sendable - static func show(req: Request) async throws -> HTML { - return Signup.View(path: req.url.path, model: Signup.Model(errorMessage: "")).document() - } +extension Portal { - @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() + 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 index 8eeca6ca4..ea55ee0d0 100644 --- a/Sources/App/Controllers/Portal/VerifyController.swift +++ b/Sources/App/Controllers/Portal/VerifyController.swift @@ -7,33 +7,36 @@ import SotoCognitoIdentityProvider import SotoCognitoIdentity import Dependencies -enum VerifyController { - @Sendable - static func show(req: Request) async throws -> HTML { - return Verify.View(path: req.url.path, model: Verify.Model(email: "")).document() - } +extension Portal { - @Sendable - static func verify(req: Request) async throws -> HTML { - @Dependency(\.cognito) var cognito - struct VerifyInformation: Content { - var email: String - var confirmationCode: String + enum VerifyController { + @Sendable + static func show(req: Request) async throws -> HTML { + return Verify.View(path: req.url.path, model: Verify.Model(email: "")).document() } - 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() + + @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/Controllers/Portal/Cognito.swift b/Sources/App/Core/Cognito.swift similarity index 99% rename from Sources/App/Controllers/Portal/Cognito.swift rename to Sources/App/Core/Cognito.swift index efa7287b4..e7e48d360 100644 --- a/Sources/App/Controllers/Portal/Cognito.swift +++ b/Sources/App/Core/Cognito.swift @@ -3,6 +3,7 @@ import SotoCognitoAuthentication import SotoCognitoIdentityProvider import SotoCognitoIdentity + struct Cognito { @Sendable static func authenticate(req: Request, username: String, password: String) async throws -> CognitoAuthenticateResponse { diff --git a/Sources/App/Controllers/Portal/SessionAuthentication.swift b/Sources/App/Core/SessionAuthentication.swift similarity index 100% rename from Sources/App/Controllers/Portal/SessionAuthentication.swift rename to Sources/App/Core/SessionAuthentication.swift diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 37b21efe9..745f14187 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -146,51 +146,51 @@ func routes(_ app: Application) throws { if environment.current() != .production { do { - redirect.get(SiteURL.portal.pathComponents, use: PortalController.show) + redirect.get(SiteURL.portal.pathComponents, use: Portal.PortalController.show) .excludeFromOpenAPI() } do { - auth.get(SiteURL.login.pathComponents, use: LoginController.show) - auth.post(SiteURL.login.pathComponents, use: LoginController.login) + auth.get(SiteURL.login.pathComponents, use: Portal.LoginController.show) + auth.post(SiteURL.login.pathComponents, use: Portal.LoginController.login) .excludeFromOpenAPI() } do { - auth.get(SiteURL.signup.pathComponents, use: SignupController.show) + auth.get(SiteURL.signup.pathComponents, use: Portal.SignupController.show) .excludeFromOpenAPI() - auth.post(SiteURL.signup.pathComponents, use: SignupController.signup) + auth.post(SiteURL.signup.pathComponents, use: Portal.SignupController.signup) .excludeFromOpenAPI() } do { - auth.get(SiteURL.verify.pathComponents, use: VerifyController.show) + auth.get(SiteURL.verify.pathComponents, use: Portal.VerifyController.show) .excludeFromOpenAPI() - auth.post(SiteURL.verify.pathComponents, use: VerifyController.verify) + auth.post(SiteURL.verify.pathComponents, use: Portal.VerifyController.verify) .excludeFromOpenAPI() } do { - auth.post(SiteURL.logout.pathComponents, use: LogoutController.logout) + auth.post(SiteURL.logout.pathComponents, use: Portal.LogoutController.logout) .excludeFromOpenAPI() } do { - auth.post(SiteURL.deleteAccount.pathComponents, use: DeleteAccountController.deleteAccount) + auth.post(SiteURL.deleteAccount.pathComponents, use: Portal.DeleteAccountController.deleteAccount) .excludeFromOpenAPI() } do { - app.get(SiteURL.forgotPassword.pathComponents, use: ForgotPasswordController.show) + app.get(SiteURL.forgotPassword.pathComponents, use: Portal.ForgotPasswordController.show) .excludeFromOpenAPI() - app.post(SiteURL.forgotPassword.pathComponents, use: ForgotPasswordController.forgotPasswordEmail) + app.post(SiteURL.forgotPassword.pathComponents, use: Portal.ForgotPasswordController.forgotPasswordEmail) .excludeFromOpenAPI() } do { - app.get(SiteURL.resetPassword.pathComponents, use: ResetController.show) + app.get(SiteURL.resetPassword.pathComponents, use: Portal.ResetController.show) .excludeFromOpenAPI() - app.post(SiteURL.resetPassword.pathComponents, use: ResetController.resetPassword) + app.post(SiteURL.resetPassword.pathComponents, use: Portal.ResetController.resetPassword) .excludeFromOpenAPI() } } From 7b4788907458e0ec9701513bbba9acae0aeadbda Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 11 Feb 2025 13:33:07 -0600 Subject: [PATCH 30/44] implement dbId dependency in portal tests --- Tests/AppTests/PortalTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/AppTests/PortalTests.swift b/Tests/AppTests/PortalTests.swift index 57f28300a..6bf132f2e 100644 --- a/Tests/AppTests/PortalTests.swift +++ b/Tests/AppTests/PortalTests.swift @@ -90,6 +90,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) }, afterResponse: { res in @@ -102,6 +103,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) }, afterResponse: { res in @@ -115,6 +117,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) }, afterResponse: { res in @@ -127,6 +130,7 @@ class PortalTests: AppTestCase { try withDependencies { let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in } $0.cognito.signup = mock + $0.environment.dbId = { nil } } operation: { try app.test(.POST, "signup", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) }, afterResponse: { res in @@ -140,6 +144,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "signup", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) }, afterResponse: { res in @@ -153,6 +158,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "signup") { res in XCTAssertTrue(res.body.string.contains("error")) @@ -164,6 +170,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) }, afterResponse: { res in @@ -177,6 +184,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) }, afterResponse: { res in @@ -190,6 +198,7 @@ class PortalTests: AppTestCase { 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 app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) }, afterResponse: { res in @@ -202,6 +211,7 @@ class PortalTests: AppTestCase { try withDependencies { let mock: @Sendable (_ req: Request, _ username: String) async throws -> Void = { _, _ in } $0.cognito.forgotPassword = mock + $0.environment.dbId = { nil } } operation: { try app.test(.POST, "forgot-password", beforeRequest: { req in try req.content.encode(["email": "testemail"]) }, afterResponse: { res in @@ -216,6 +226,7 @@ class PortalTests: AppTestCase { 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 app.test(.POST, "forgot-password", beforeRequest: { req in try req.content.encode(["email": "testemail"]) }, afterResponse: { res in @@ -245,6 +256,7 @@ class PortalTests: AppTestCase { try withDependencies { let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in } $0.cognito.confirmSignUp = mock + $0.environment.dbId = { nil } } operation: { try app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) }, afterResponse: { res in @@ -258,6 +270,7 @@ class PortalTests: AppTestCase { try 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 app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) }, afterResponse: { res in @@ -271,6 +284,7 @@ class PortalTests: AppTestCase { 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 app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) }, afterResponse: { res in @@ -310,6 +324,7 @@ class PortalTests: AppTestCase { struct SomeError: Error {} let mock: @Sendable (_ req: Request) async throws -> Void = { _ in throw SomeError() } $0.cognito.deleteUser = mock + $0.environment.dbId = { nil } } operation: { try app.test(.POST, "delete") { res in XCTAssertEqual(res.status, .internalServerError) From 22a9e10c3cf61433ce74e1e85d6c288c61894b81 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 12 Feb 2025 12:56:14 -0600 Subject: [PATCH 31/44] minor front-end styling and renaming --- FrontEnd/main.scss | 2 +- FrontEnd/styles/{manage.scss => portal.scss} | 4 ++-- Sources/App/Views/Portal/ForgotPassword+View.swift | 2 +- Sources/App/Views/Portal/Login+View.swift | 2 -- Sources/App/Views/Portal/Portal+View.swift | 3 +++ Sources/App/Views/Portal/Signup+View.swift | 2 +- Sources/App/Views/Portal/Successful+Password+Change.swift | 1 + Sources/App/Views/Portal/Verify+View.swift | 4 ++-- 8 files changed, 11 insertions(+), 9 deletions(-) rename FrontEnd/styles/{manage.scss => portal.scss} (96%) diff --git a/FrontEnd/main.scss b/FrontEnd/main.scss index feb6033ee..5b955ec48 100644 --- a/FrontEnd/main.scss +++ b/FrontEnd/main.scss @@ -46,4 +46,4 @@ $mobile-breakpoint: 740px; @import 'styles/tab_bar'; @import 'styles/validate_manifest'; @import 'styles/vega_charts'; -@import 'styles/manage'; +@import 'styles/portal'; diff --git a/FrontEnd/styles/manage.scss b/FrontEnd/styles/portal.scss similarity index 96% rename from FrontEnd/styles/manage.scss rename to FrontEnd/styles/portal.scss index 7bc179242..76fba5091 100644 --- a/FrontEnd/styles/manage.scss +++ b/FrontEnd/styles/portal.scss @@ -16,12 +16,12 @@ // Styles for authentication pages (login, signup, etc.) // ------------------------------------------------------------------------- -.manage-page { +.portal-page { height: 55vh; padding-top: 10%; } -.manage-form-inputs { +.portal-form-inputs { display: flex; flex-direction: column; width: 50%; diff --git a/Sources/App/Views/Portal/ForgotPassword+View.swift b/Sources/App/Views/Portal/ForgotPassword+View.swift index 45f45f303..60a6caadd 100644 --- a/Sources/App/Views/Portal/ForgotPassword+View.swift +++ b/Sources/App/Views/Portal/ForgotPassword+View.swift @@ -22,7 +22,7 @@ enum ForgotPassword { override func content() -> Node { .div( - .class("manage-page"), + .class("portal-page"), .h2("An email will be sent with a reset code"), .forgotPasswordForm(), .text(model.errorMessage) diff --git a/Sources/App/Views/Portal/Login+View.swift b/Sources/App/Views/Portal/Login+View.swift index 08246da36..c5332e7d4 100644 --- a/Sources/App/Views/Portal/Login+View.swift +++ b/Sources/App/Views/Portal/Login+View.swift @@ -76,7 +76,6 @@ extension Node where Context: HTML.BodyContext { extension Node where Context == HTML.FormContext { static func emailField(email: String = "") -> Self { .input( - .class("manage-form-inputs"), .id("email"), .name("email"), .type(.email), @@ -89,7 +88,6 @@ extension Node where Context == HTML.FormContext { static func passwordField(password: String = "", passwordFieldText: String = "Enter password") -> Self { .input( - .class("manage-form-inputs"), .id("password"), .name("password"), .type(.password), diff --git a/Sources/App/Views/Portal/Portal+View.swift b/Sources/App/Views/Portal/Portal+View.swift index 6fee2a95e..343d29328 100644 --- a/Sources/App/Views/Portal/Portal+View.swift +++ b/Sources/App/Views/Portal/Portal+View.swift @@ -22,6 +22,7 @@ enum PortalPage { override func content() -> Node { .div( + .class("portal-page"), .h2("Portal"), .logoutButton(), .deleteButton(), @@ -35,6 +36,7 @@ enum PortalPage { 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"), @@ -47,6 +49,7 @@ extension Node where Context: HTML.BodyContext { static func deleteButton() -> Self { .form( + .class("portal-form-inputs"), .action(SiteURL.deleteAccount.relativeURL()), .method(.post), .data(named: "turbo", value: "false"), diff --git a/Sources/App/Views/Portal/Signup+View.swift b/Sources/App/Views/Portal/Signup+View.swift index f775353dc..e5b9b6051 100644 --- a/Sources/App/Views/Portal/Signup+View.swift +++ b/Sources/App/Views/Portal/Signup+View.swift @@ -22,7 +22,7 @@ enum Signup { override func content() -> Node { .div( - .class("manage-page"), + .class("portal-page"), .h2("Signup"), .signupForm(), .text(model.errorMessage) diff --git a/Sources/App/Views/Portal/Successful+Password+Change.swift b/Sources/App/Views/Portal/Successful+Password+Change.swift index 1f025c3b4..d094dd6a8 100644 --- a/Sources/App/Views/Portal/Successful+Password+Change.swift +++ b/Sources/App/Views/Portal/Successful+Password+Change.swift @@ -22,6 +22,7 @@ enum SuccessfulChange { override func content() -> Node { .div( + .class("portal-page"), .text(self.model.successMessage), .loginButton() ) diff --git a/Sources/App/Views/Portal/Verify+View.swift b/Sources/App/Views/Portal/Verify+View.swift index f56236e3c..79edefd42 100644 --- a/Sources/App/Views/Portal/Verify+View.swift +++ b/Sources/App/Views/Portal/Verify+View.swift @@ -23,7 +23,7 @@ enum Verify { override func content() -> Node { .div( - .class("manage-page"), + .class("portal-page"), .h2("Please enter the confirmation code sent to your email"), .verifyForm(email: model.email), .text(model.errorMessage) @@ -57,7 +57,7 @@ extension Node where Context: HTML.BodyContext { extension Node where Context == HTML.FormContext { static func codeField(code: String = "") -> Self { .input( - .class("manage-form-inputs"), + .class("portal-form-inputs"), .id("confirmationCode"), .name("confirmationCode"), .type(.text), From 36090b8d6c22d51c3f3eeed57eec0f1a7f6034f8 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 12 Feb 2025 19:54:27 -0600 Subject: [PATCH 32/44] organize plot extensions --- Sources/App/Views/Plot+Extensions.swift | 37 +++++++++++++++++++ .../Views/Portal/ForgotPassword+View.swift | 1 - Sources/App/Views/Portal/Login+View.swift | 27 -------------- Sources/App/Views/Portal/Portal+View.swift | 1 - Sources/App/Views/Portal/Reset+View.swift | 3 +- Sources/App/Views/Portal/Signup+View.swift | 1 - .../Portal/Successful+Password+Change.swift | 5 +-- Sources/App/Views/Portal/Verify+View.swift | 18 +-------- 8 files changed, 41 insertions(+), 52 deletions(-) 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 index 60a6caadd..80ae1a769 100644 --- a/Sources/App/Views/Portal/ForgotPassword+View.swift +++ b/Sources/App/Views/Portal/ForgotPassword+View.swift @@ -31,7 +31,6 @@ enum ForgotPassword { } } -// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { static func forgotPasswordForm(email: String = "") -> Self { .form( diff --git a/Sources/App/Views/Portal/Login+View.swift b/Sources/App/Views/Portal/Login+View.swift index c5332e7d4..0fcb3cbb2 100644 --- a/Sources/App/Views/Portal/Login+View.swift +++ b/Sources/App/Views/Portal/Login+View.swift @@ -34,7 +34,6 @@ enum Login { } } -// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { static func loginForm(email: String = "", password: String = "") -> Self { .div( @@ -73,29 +72,3 @@ extension Node where Context: HTML.BodyContext { } } -extension Node where Context == HTML.FormContext { - 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) - ) - } -} - diff --git a/Sources/App/Views/Portal/Portal+View.swift b/Sources/App/Views/Portal/Portal+View.swift index 343d29328..7c68a800f 100644 --- a/Sources/App/Views/Portal/Portal+View.swift +++ b/Sources/App/Views/Portal/Portal+View.swift @@ -32,7 +32,6 @@ enum PortalPage { } } -// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { static func logoutButton() -> Self { .form( diff --git a/Sources/App/Views/Portal/Reset+View.swift b/Sources/App/Views/Portal/Reset+View.swift index 399e9513a..e3f32c07a 100644 --- a/Sources/App/Views/Portal/Reset+View.swift +++ b/Sources/App/Views/Portal/Reset+View.swift @@ -31,14 +31,13 @@ enum Reset { } } -// TODO: move to plot extensions 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"), - .codeField(code: code), + .confirmationCodeField(code: code), .emailField(email: email) , .passwordField(password: password, passwordFieldText: "Enter new password"), .button( diff --git a/Sources/App/Views/Portal/Signup+View.swift b/Sources/App/Views/Portal/Signup+View.swift index e5b9b6051..fc69e5745 100644 --- a/Sources/App/Views/Portal/Signup+View.swift +++ b/Sources/App/Views/Portal/Signup+View.swift @@ -31,7 +31,6 @@ enum Signup { } } -// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { static func signupForm(email: String = "", password: String = "") -> Self { .form( diff --git a/Sources/App/Views/Portal/Successful+Password+Change.swift b/Sources/App/Views/Portal/Successful+Password+Change.swift index d094dd6a8..d7d25ceb3 100644 --- a/Sources/App/Views/Portal/Successful+Password+Change.swift +++ b/Sources/App/Views/Portal/Successful+Password+Change.swift @@ -24,15 +24,14 @@ enum SuccessfulChange { .div( .class("portal-page"), .text(self.model.successMessage), - .loginButton() + .loginRedirectButton() ) } } } -// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { - static func loginButton() -> Self { + static func loginRedirectButton() -> Self { .form( .action(SiteURL.login.relativeURL()), .button( diff --git a/Sources/App/Views/Portal/Verify+View.swift b/Sources/App/Views/Portal/Verify+View.swift index 79edefd42..5e6a1c86c 100644 --- a/Sources/App/Views/Portal/Verify+View.swift +++ b/Sources/App/Views/Portal/Verify+View.swift @@ -32,7 +32,6 @@ enum Verify { } } -// TODO: move to plot extensions extension Node where Context: HTML.BodyContext { static func verifyForm(email: String = "", code: String = "") -> Self { .form( @@ -44,7 +43,7 @@ extension Node where Context: HTML.BodyContext { .type(.hidden), .value(email) ), - .codeField(code: code), + .confirmationCodeField(code: code), .data(named: "turbo", value: "false"), .button( .text("Confirm sign up"), @@ -53,18 +52,3 @@ extension Node where Context: HTML.BodyContext { ) } } - -extension Node where Context == HTML.FormContext { - static func codeField(code: String = "") -> Self { - .input( - .class("portal-form-inputs"), - .id("confirmationCode"), - .name("confirmationCode"), - .type(.text), - .placeholder("Confirmation code"), - .spellcheck(false), - .autocomplete(false), - .value(code) - ) - } -} From d71e01f2e433b2cb11ec840d0fdcb039839351f2 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Tue, 4 Mar 2025 21:22:11 -0600 Subject: [PATCH 33/44] error handling --- Sources/App/Core/Cognito.swift | 181 ++++++++++++++++++++------------- Sources/App/Core/SiteURL.swift | 2 +- 2 files changed, 109 insertions(+), 74 deletions(-) diff --git a/Sources/App/Core/Cognito.swift b/Sources/App/Core/Cognito.swift index e7e48d360..f1ef8c11b 100644 --- a/Sources/App/Core/Cognito.swift +++ b/Sources/App/Core/Cognito.swift @@ -5,110 +5,145 @@ import SotoCognitoIdentity struct Cognito { - @Sendable + @Sendable static func authenticate(req: Request, username: String, password: String) async throws -> CognitoAuthenticateResponse { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - 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 awsClient.syncShutdown() - return response + 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 awsClient.syncShutdown() + return response + } catch { + try awsClient.syncShutdown() + 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)) - 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 awsClient.syncShutdown() + 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 awsClient.syncShutdown() + } catch { + try awsClient.syncShutdown() + throw error + } } @Sendable static func signup(req: Request, username: String, password: String) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - 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 awsClient.syncShutdown() + 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 awsClient.syncShutdown() + } catch { + try awsClient.syncShutdown() + throw error + } } @Sendable static func forgotPassword(req: Request, username: String) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - 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 awsClient.syncShutdown() + 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 awsClient.syncShutdown() + } catch { + try awsClient.syncShutdown() + 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)) - 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 awsClient.syncShutdown() + 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 awsClient.syncShutdown() + } catch { + try awsClient.syncShutdown() + throw error + } } @Sendable static func confirmSignUp(req: Request, username: String, confirmationCode: String) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - 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 awsClient.syncShutdown() + 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 awsClient.syncShutdown() + } catch { + try awsClient.syncShutdown() + throw error + } } @Sendable static func deleteUser(req: Request) async throws { let awsClient = AWSClient(httpClientProvider: .shared(req.application.http.client.shared)) - 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 awsClient.syncShutdown() + 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 awsClient.syncShutdown() + } catch { + try awsClient.syncShutdown() + throw error + } } } diff --git a/Sources/App/Core/SiteURL.swift b/Sources/App/Core/SiteURL.swift index fdb6ddcf7..e6a4860c0 100644 --- a/Sources/App/Core/SiteURL.swift +++ b/Sources/App/Core/SiteURL.swift @@ -263,7 +263,7 @@ enum SiteURL: Resourceable, Sendable { return "ready-for-swift-6" case .resetPassword: - return "reset" + return "reset-password" case .rssPackages: return "packages.rss" From d8c4834ccab036969c9018bf5808cba242676586 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 5 Mar 2025 08:08:02 -0600 Subject: [PATCH 34/44] await shutdown --- Sources/App/Core/Cognito.swift | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/App/Core/Cognito.swift b/Sources/App/Core/Cognito.swift index f1ef8c11b..4a24b47aa 100644 --- a/Sources/App/Core/Cognito.swift +++ b/Sources/App/Core/Cognito.swift @@ -18,10 +18,10 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) let response = try await req.application.cognito.authenticatable.authenticate(username: username, password: password) - try awsClient.syncShutdown() + try await awsClient.shutdown() return response } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } @@ -39,9 +39,9 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) let _ = try await req.application.cognito.authenticatable.authenticate(accessToken: sessionID, on: req.eventLoop) - try awsClient.syncShutdown() + try await awsClient.shutdown() } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } @@ -59,9 +59,9 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) _ = try await req.application.cognito.authenticatable.signUp(username: username, password: password, attributes: [:], on:req.eventLoop) - try awsClient.syncShutdown() + try await awsClient.shutdown() } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } @@ -79,9 +79,9 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) try await req.application.cognito.authenticatable.forgotPassword(username: username) - try awsClient.syncShutdown() + try await awsClient.shutdown() } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } @@ -99,9 +99,9 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) try await req.application.cognito.authenticatable.confirmForgotPassword(username: username, newPassword: password, confirmationCode: confirmationCode) - try awsClient.syncShutdown() + try await awsClient.shutdown() } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } @@ -119,9 +119,9 @@ struct Cognito { ) req.application.cognito.authenticatable = CognitoAuthenticatable(configuration: awsCognitoConfiguration) try await req.application.cognito.authenticatable.confirmSignUp(username: username, confirmationCode: confirmationCode) - try awsClient.syncShutdown() + try await awsClient.shutdown() } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } @@ -140,9 +140,9 @@ struct Cognito { 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 awsClient.syncShutdown() + try await awsClient.shutdown() } catch { - try awsClient.syncShutdown() + try await awsClient.shutdown() throw error } } From c6dd19ce392e75d1982a593a8e9e131a45e0ac04 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Sat, 8 Mar 2025 18:04:41 -0600 Subject: [PATCH 35/44] update reset route in tests --- Tests/AppTests/PortalTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/AppTests/PortalTests.swift b/Tests/AppTests/PortalTests.swift index 6bf132f2e..8ea1b7fce 100644 --- a/Tests/AppTests/PortalTests.swift +++ b/Tests/AppTests/PortalTests.swift @@ -172,7 +172,7 @@ class PortalTests: AppTestCase { $0.cognito.resetPassword = mock $0.environment.dbId = { nil } } operation: { - try app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + try app.test(.POST, "reset-password", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) }, afterResponse: { res in XCTAssertEqual(res.status, .ok) XCTAssertTrue(res.body.string.contains("Successfully changed password")) @@ -186,7 +186,7 @@ class PortalTests: AppTestCase { $0.cognito.resetPassword = mock $0.environment.dbId = { nil } } operation: { - try app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + try app.test(.POST, "reset-password", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) }, afterResponse: { res in XCTAssertTrue(res.body.string.contains("There was an error")) }) @@ -200,7 +200,7 @@ class PortalTests: AppTestCase { $0.cognito.resetPassword = mock $0.environment.dbId = { nil } } operation: { - try app.test(.POST, "reset", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) + try app.test(.POST, "reset-password", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) }, afterResponse: { res in XCTAssertTrue(res.body.string.contains("An unknown error occurred")) }) From c40a9ba406a68f0031427a0e61acde538c07efc4 Mon Sep 17 00:00:00 2001 From: Rahaf Aljerwi Date: Wed, 12 Mar 2025 00:10:46 -0500 Subject: [PATCH 36/44] remove excludeFromOpenAPI --- Sources/App/routes.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 2464f5968..28751d08d 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -144,51 +144,39 @@ func routes(_ app: Application) throws { if environment.current() != .production { do { redirect.get(SiteURL.portal.pathComponents, use: Portal.PortalController.show) - .excludeFromOpenAPI() } do { auth.get(SiteURL.login.pathComponents, use: Portal.LoginController.show) auth.post(SiteURL.login.pathComponents, use: Portal.LoginController.login) - .excludeFromOpenAPI() } do { auth.get(SiteURL.signup.pathComponents, use: Portal.SignupController.show) - .excludeFromOpenAPI() auth.post(SiteURL.signup.pathComponents, use: Portal.SignupController.signup) - .excludeFromOpenAPI() } do { auth.get(SiteURL.verify.pathComponents, use: Portal.VerifyController.show) - .excludeFromOpenAPI() auth.post(SiteURL.verify.pathComponents, use: Portal.VerifyController.verify) - .excludeFromOpenAPI() } do { auth.post(SiteURL.logout.pathComponents, use: Portal.LogoutController.logout) - .excludeFromOpenAPI() } do { auth.post(SiteURL.deleteAccount.pathComponents, use: Portal.DeleteAccountController.deleteAccount) - .excludeFromOpenAPI() } do { app.get(SiteURL.forgotPassword.pathComponents, use: Portal.ForgotPasswordController.show) - .excludeFromOpenAPI() app.post(SiteURL.forgotPassword.pathComponents, use: Portal.ForgotPasswordController.forgotPasswordEmail) - .excludeFromOpenAPI() } do { app.get(SiteURL.resetPassword.pathComponents, use: Portal.ResetController.show) - .excludeFromOpenAPI() app.post(SiteURL.resetPassword.pathComponents, use: Portal.ResetController.resetPassword) - .excludeFromOpenAPI() } } From 7c2b08f5546f29436277885ec52b235aef3e3c75 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Tue, 25 Mar 2025 15:53:54 +0000 Subject: [PATCH 37/44] Converted tests to Swift Testing. --- Tests/AppTests/AllTests.swift | 1 + Tests/AppTests/PortalTests.swift | 424 ++++++++++++++++++------------- 2 files changed, 252 insertions(+), 173 deletions(-) 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 index 8ea1b7fce..0a538a726 100644 --- a/Tests/AppTests/PortalTests.swift +++ b/Tests/AppTests/PortalTests.swift @@ -14,28 +14,28 @@ @testable import App -import XCTest +import Testing + import Fluent import Vapor import Dependencies import SotoCognitoAuthenticationKit +extension AllTests.PortalTests { - - -class PortalTests: AppTestCase { - - func test_portal_route_protected() throws { - try app.test(.GET, "portal") { res in - XCTAssertEqual(res.status, .seeOther) - if let location = res.headers.first(name: .location) { - XCTAssertEqual("/login", location) - } + @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) + } + }) } } - - func test_login_successful_redirect() throws { - try withDependencies { + + @Test func test_login_successful_redirect() async throws { + try await withDependencies { let jsonData: Data = """ { "authenticated": { @@ -50,18 +50,21 @@ class PortalTests: AppTestCase { let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in return response } $0.cognito.authenticate = mock } operation: { - try app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .seeOther) - if let location = res.headers.first(name: .location) { - XCTAssertEqual("/portal", location) - } - }) + 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) + } + }) + } } } - - func test_successful_login_secure_cookie_set() throws { - try withDependencies { + + @Test func test_successful_login_secure_cookie_set() async throws { + try await withDependencies { let jsonData: Data = """ { "authenticated": { @@ -73,264 +76,339 @@ class PortalTests: AppTestCase { """.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 } + let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in + return response + } $0.cognito.authenticate = mock } operation: { - try app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - if let cookieHeader = res.headers.first(name: .setCookie) { - XCTAssertTrue(cookieHeader.contains("HttpOnly")) - XCTAssertTrue(cookieHeader.contains("Secure")) - } - }) + 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) + } + }) + } } } - - func test_login_soto_error() throws { - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in throw SotoCognitoError.unauthorized(reason: "reason") } + + @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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .unauthorized) - }) + 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) + }) + } } } - - func test_login_some_aws_client_error() throws { - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in throw AWSClientError.accessDenied } + + @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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .unauthorized) - }) + 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) + }) + } } } - - func test_login_throw_other_error() throws { + + @Test func test_login_throw_other_error() async throws { struct SomeError: Error {} - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> CognitoAuthenticateResponse = { _, _, _ in throw SomeError() } + + 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 app.test(.POST, "login", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .unauthorized) - }) + 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) + }) + } } } - - func test_signup_successful_view_change() throws { - try withDependencies { + + @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 app.test(.POST, "signup", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertTrue(res.body.string.contains("Verify")) - }) + 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) + }) + } } } - - func test_signup_some_aws_error() throws { - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in throw AWSClientError.accessDenied } + + @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 app.test(.POST, "signup", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword"]) - }, afterResponse: { res in - XCTAssertTrue(res.body.string.contains("There was an error")) - }) + 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) + }) + } } } - - func test_signup_throw_some_error() throws { + + @Test func test_signup_throw_some_error() async throws { struct SomeError: Error {} - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ password: String) async throws -> Void = { _, _, _ in throw SomeError() } + + 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 app.test(.POST, "signup") { res in - XCTAssertTrue(res.body.string.contains("error")) + try await withApp { app in + try await app.test(.POST, "signup") { res async throws in + #expect(res.body.string.contains("error") == true) + } } } } - - func test_reset_password_successful_view_change() throws { - try withDependencies { + + @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 app.test(.POST, "reset-password", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertTrue(res.body.string.contains("Successfully changed password")) - }) + 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) + }) + } } } - - func test_reset_pass_throws_aws_error() throws { - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ password: String, _ confirmationCode: String) async throws -> Void = { _, _, _, _ in throw AWSClientError.accessDenied } + + @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 app.test(.POST, "reset-password", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) - }, afterResponse: { res in - XCTAssertTrue(res.body.string.contains("There was an error")) - }) + 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) + }) + } } } - - func test_reset_pass_throws_other_error() throws { - try withDependencies { + + @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() } + 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 app.test(.POST, "reset-password", beforeRequest: { req in try req.content.encode(["email": "testemail", "password": "testpassword", "confirmationCode": "123"]) - }, afterResponse: { res in - XCTAssertTrue(res.body.string.contains("An unknown error occurred")) - }) + 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")) + }) + } } } - - func test_forgot_pass_successful_view_change() throws { - try withDependencies { + + @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 app.test(.POST, "forgot-password", beforeRequest: { req in try req.content.encode(["email": "testemail"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertTrue(res.body.string.contains("Reset Password")) - }) + 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) + }) + } } } - - func test_forgot_pass_throws() throws { - try withDependencies { + + @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 app.test(.POST, "forgot-password", beforeRequest: { req in try req.content.encode(["email": "testemail"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertTrue(res.body.string.contains("An error occurred")) - }) + 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) + }) + } } } - - func test_logout_successful_redirect() throws { - try app.test(.POST, "logout") { res in - XCTAssertEqual(res.status, .seeOther) - if let location = res.headers.first(name: .location) { - XCTAssertEqual("/", location) + + @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) + } } } } - - func test_logout_session_destroyed() throws { - try app.test(.POST, "logout") { res in - let cookie = res.headers.setCookie?["vapor-session"] - XCTAssertNil(cookie) + + @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) + } } } - - func test_verify_successful_view_Change() throws { - try withDependencies { + + @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 app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) - }, afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertTrue(res.body.string.contains("Successfully confirmed signup")) - }) + 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) + }) + } } } - - func test_verify_throws_aws_error() throws { - try withDependencies { - let mock: @Sendable (_ req: Request, _ username: String, _ confirmationCode: String) async throws -> Void = { _, _, _ in throw AWSClientError.accessDenied } + + @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 app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) - }, afterResponse: { res in - XCTAssertTrue(res.body.string.contains("There was an error")) - }) + 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) + }) + } } } - - func test_verify_throws_some_error() throws { - try withDependencies { + + @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() } + 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 app.test(.POST, "verify", beforeRequest: { req in try req.content.encode(["email": "testemail", "confirmationCode": "123"]) - }, afterResponse: { res in - XCTAssertTrue(res.body.string.contains("An unknown error occurred")) - }) + 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")) + }) + } } } - - func test_delete_successful_redirect() throws { - try withDependencies { + + @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 app.test(.POST, "delete") { res in - XCTAssertEqual(res.status, .seeOther) - if let location = res.headers.first(name: .location) { - XCTAssertEqual("/", location) + 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) + } } } } } - - func test_delete_session_destroyed() throws { - try withDependencies { + + @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 app.test(.POST, "delete") { res in - let cookie = res.headers.setCookie?["vapor-session"] - XCTAssertNil(cookie) + 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) + } } } } - - func test_delete_throws() throws { - try withDependencies { + + @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 app.test(.POST, "delete") { res in - XCTAssertEqual(res.status, .internalServerError) - XCTAssertTrue(res.body.string.contains("An unknown error occurred")) + 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")) + } } } } } - From 1f75cdd97ebf9db5fdf220782e9cbbae849660ad Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Tue, 25 Mar 2025 18:56:57 +0000 Subject: [PATCH 38/44] Snapshots. --- .../WebpageSnapshotTests/HomeIndex_document_development.1.html | 3 +++ 1 file changed, 3 insertions(+) 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 +
  • From 7d4093907b333a6f0dfdbefc6d82bfbb834c6d14 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Thu, 10 Apr 2025 13:25:53 +0100 Subject: [PATCH 39/44] Sorted CSS imports. --- FrontEnd/main.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FrontEnd/main.scss b/FrontEnd/main.scss index 5b955ec48..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'; @@ -46,4 +47,3 @@ $mobile-breakpoint: 740px; @import 'styles/tab_bar'; @import 'styles/validate_manifest'; @import 'styles/vega_charts'; -@import 'styles/portal'; From 31e7f1b59e33b54b1fb15c6049fe56cd3e45785e Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Thu, 10 Apr 2025 14:27:27 +0100 Subject: [PATCH 40/44] Added an account image to the CSS. --- FrontEnd/styles/images.scss | 4 ++++ Resources/SVGs/account~dark.svg | 1 + Resources/SVGs/account~light.svg | 1 + 3 files changed, 6 insertions(+) create mode 100644 Resources/SVGs/account~dark.svg create mode 100644 Resources/SVGs/account~light.svg 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/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 From c87d083d5b211c8db71c52e408b460a324bdeafc Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Thu, 10 Apr 2025 14:50:33 +0100 Subject: [PATCH 41/44] =?UTF-8?q?Replaced=20the=20=E2=80=9CPortal=E2=80=9D?= =?UTF-8?q?=20link=20with=20an=20icon.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FrontEnd/styles/header_footer.scss | 8 ++++++++ FrontEnd/styles/search.scss | 2 +- Public/images/portal.svg | 1 + Sources/App/Views/NavMenuItems.swift | 9 +++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 Public/images/portal.svg 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/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/Sources/App/Views/NavMenuItems.swift b/Sources/App/Views/NavMenuItems.swift index 303a30016..62fa37ad9 100644 --- a/Sources/App/Views/NavMenuItems.swift +++ b/Sources/App/Views/NavMenuItems.swift @@ -37,7 +37,7 @@ enum NavMenuItem { return .li( .a( .href(SiteURL.addAPackage.relativeURL()), - "Add a Package" + "Add Package" ) ) case .blog: @@ -68,9 +68,14 @@ enum NavMenuItem { ) case .portal: return .li( + .class("portal"), .a( .href(SiteURL.portal.relativeURL()), - "Portal" + .img( + .alt("Portal"), + .src(SiteURL.images("portal.svg").relativeURL()), + .width(20) + ) ) ) } From cc08f45fcc22ec3acaf9327f86eef57bd1386857 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Thu, 10 Apr 2025 15:18:20 +0100 Subject: [PATCH 42/44] Setup for styling the login form a little. --- Sources/App/Views/Portal/Login+View.swift | 46 ++++++++++++----------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/Sources/App/Views/Portal/Login+View.swift b/Sources/App/Views/Portal/Login+View.swift index 0fcb3cbb2..2abc96796 100644 --- a/Sources/App/Views/Portal/Login+View.swift +++ b/Sources/App/Views/Portal/Login+View.swift @@ -22,13 +22,15 @@ enum Login { override func content() -> Node { .div( - .h2("Login"), + .h2("Login to Swift Package Index"), .loginForm(), - .text(model.errorMessage), - .h2("Dont have an account?"), - .signupButton(), - .h2("Forgot password?"), - .forgotPassword() + .if(model.errorMessage.isEmpty == false, + .p( + .text(model.errorMessage) + ) + ), + .signupButton("Create an account"), + .forgotPassword("Reset your password") ) } } @@ -36,36 +38,36 @@ enum Login { extension Node where Context: HTML.BodyContext { static func loginForm(email: String = "", password: String = "") -> Self { - .div( - .form( - .action(SiteURL.login.relativeURL()), - .method(.post), - .data(named: "turbo", value: "false"), - .emailField(email: email) , - .passwordField(password: password), - .button( - .text("Login"), - .type(.submit) - ) + .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() -> Self { + + static func signupButton(_ text: String) -> Self { .form( + .class("signup"), .action(SiteURL.signup.relativeURL()), .button( - .text("Create an account"), + .text(text), .type(.submit) ) ) } - static func forgotPassword() -> Self { + static func forgotPassword(_ text: String) -> Self { .form( + .class("forgot"), .action(SiteURL.forgotPassword.relativeURL()), .button( - .text("Reset password"), + .text(text), .type(.submit) ) ) From 42c38c898b8d646f5070fbd174a09d4fb4475d57 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Thu, 10 Apr 2025 15:19:49 +0100 Subject: [PATCH 43/44] Better naming for the form container div. --- FrontEnd/styles/portal.scss | 4 ++-- .../App/Views/Portal/ForgotPassword+View.swift | 14 +++++++------- Sources/App/Views/Portal/Portal+View.swift | 16 ++++++++-------- Sources/App/Views/Portal/Signup+View.swift | 12 ++++++------ .../Portal/Successful+Password+Change.swift | 12 ++++++------ Sources/App/Views/Portal/Verify+View.swift | 14 +++++++------- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/FrontEnd/styles/portal.scss b/FrontEnd/styles/portal.scss index 76fba5091..6ea0582e7 100644 --- a/FrontEnd/styles/portal.scss +++ b/FrontEnd/styles/portal.scss @@ -16,9 +16,9 @@ // Styles for authentication pages (login, signup, etc.) // ------------------------------------------------------------------------- -.portal-page { +.portal-form-container { height: 55vh; - padding-top: 10%; + padding: 10%; } .portal-form-inputs { diff --git a/Sources/App/Views/Portal/ForgotPassword+View.swift b/Sources/App/Views/Portal/ForgotPassword+View.swift index 80ae1a769..0fe048a5f 100644 --- a/Sources/App/Views/Portal/ForgotPassword+View.swift +++ b/Sources/App/Views/Portal/ForgotPassword+View.swift @@ -2,27 +2,27 @@ 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-page"), + .class("portal-form-container"), .h2("An email will be sent with a reset code"), .forgotPasswordForm(), .text(model.errorMessage) diff --git a/Sources/App/Views/Portal/Portal+View.swift b/Sources/App/Views/Portal/Portal+View.swift index 7c68a800f..7a4f3ac9a 100644 --- a/Sources/App/Views/Portal/Portal+View.swift +++ b/Sources/App/Views/Portal/Portal+View.swift @@ -2,27 +2,27 @@ 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-page"), + .class("portal-form-container"), .h2("Portal"), .logoutButton(), .deleteButton(), @@ -45,7 +45,7 @@ extension Node where Context: HTML.BodyContext { ) ) } - + static func deleteButton() -> Self { .form( .class("portal-form-inputs"), diff --git a/Sources/App/Views/Portal/Signup+View.swift b/Sources/App/Views/Portal/Signup+View.swift index fc69e5745..ddb4acaab 100644 --- a/Sources/App/Views/Portal/Signup+View.swift +++ b/Sources/App/Views/Portal/Signup+View.swift @@ -6,23 +6,23 @@ 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-page"), + .class("portal-form-container"), .h2("Signup"), .signupForm(), .text(model.errorMessage) diff --git a/Sources/App/Views/Portal/Successful+Password+Change.swift b/Sources/App/Views/Portal/Successful+Password+Change.swift index d7d25ceb3..8a244489a 100644 --- a/Sources/App/Views/Portal/Successful+Password+Change.swift +++ b/Sources/App/Views/Portal/Successful+Password+Change.swift @@ -6,23 +6,23 @@ 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-page"), + .class("portal-form-container"), .text(self.model.successMessage), .loginRedirectButton() ) diff --git a/Sources/App/Views/Portal/Verify+View.swift b/Sources/App/Views/Portal/Verify+View.swift index 5e6a1c86c..cd0d705bd 100644 --- a/Sources/App/Views/Portal/Verify+View.swift +++ b/Sources/App/Views/Portal/Verify+View.swift @@ -2,28 +2,28 @@ 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-page"), + .class("portal-form-container"), .h2("Please enter the confirmation code sent to your email"), .verifyForm(email: model.email), .text(model.errorMessage) From 66387d4a322d76cd4f2c83b4ba908c55741f67c3 Mon Sep 17 00:00:00 2001 From: Dave Verwer Date: Mon, 14 Apr 2025 11:19:37 +0100 Subject: [PATCH 44/44] WIP. --- Sources/App/Core/Supporters+GitHub.swift | 5 +++++ Sources/App/Views/Portal/Verify+View.swift | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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/Portal/Verify+View.swift b/Sources/App/Views/Portal/Verify+View.swift index cd0d705bd..3cd39c62c 100644 --- a/Sources/App/Views/Portal/Verify+View.swift +++ b/Sources/App/Views/Portal/Verify+View.swift @@ -24,9 +24,13 @@ enum Verify { override func content() -> Node { .div( .class("portal-form-container"), - .h2("Please enter the confirmation code sent to your email"), + .p( + .text("Please enter the confirmation code sent to your email") + ), .verifyForm(email: model.email), - .text(model.errorMessage) + .p( + .text(model.errorMessage) + ) ) } }