Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -0,0 +1,332 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Serves what an account does for itself: its first sign-in, its profile and passkeys, and opening its shop.
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct AccountController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts and their passkeys are read and changed through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
private let analytics: Analytics?
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository shops are opened through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// The store the signed-in account is read from.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an account controller.
|
||||
/// - Parameters:
|
||||
/// - accounts: the repository accounts and their passkeys are read and changed through.
|
||||
/// - catalogue: the repository shops are opened through.
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
accounts: AccountRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
sessions: SessionStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension AccountController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(.init(.Route.welcome), use: welcome)
|
||||
routes.post(.init(.Route.welcome), use: introduce)
|
||||
routes.get(.init(.Route.account), use: account)
|
||||
routes.post(.init("\(String.Route.account)/name"), use: rename)
|
||||
routes.post(.init("\(String.Route.account)/passkeys/revoke"), use: revoke)
|
||||
routes.get(.init(.Route.openShop), use: shopForm)
|
||||
routes.post(.init(.Route.openShop), use: openShop)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Account
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
/// Handles a request for the account screen.
|
||||
@Sendable
|
||||
func account(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .found)
|
||||
}
|
||||
|
||||
return try await accountPage(user, request: request, context: context)
|
||||
}
|
||||
|
||||
/// Renders the account screen — with a `422` when it reports a problem.
|
||||
func accountPage(
|
||||
_ user: User,
|
||||
problem: String? = nil,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let passkeys = try await accounts.passkeys(of: user)
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return HTMLResponse(
|
||||
status: problem == nil ? .ok : .unprocessableContent,
|
||||
additionalHeaders: .perViewer
|
||||
) { [analytics, assetVersion] in
|
||||
AccountPage(
|
||||
user: user,
|
||||
passkeys: passkeys,
|
||||
problem: problem,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a new display name from the account screen.
|
||||
@Sendable
|
||||
func rename(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
|
||||
|
||||
guard let name = Self.displayName(form.name) else {
|
||||
return try await accountPage(user, problem: Self.nameProblem, request: request, context: context)
|
||||
}
|
||||
|
||||
await sessions.update(try await accounts.rename(user, to: name))
|
||||
|
||||
return .redirect(to: .Route.account, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles the revoking of one of the account's own passkeys.
|
||||
@Sendable
|
||||
func revoke(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(RevokeForm.self, from: request, context: context)
|
||||
|
||||
try await accounts.revokePasskey(form.credential, of: user)
|
||||
|
||||
return .redirect(to: "\(String.Route.account)#passkeys", type: .normal)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - First sign-in
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
/// Handles the display name picked on first sign-in, and leads on to what the visitor came for.
|
||||
@Sendable
|
||||
func introduce(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn, type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
|
||||
let returnPath = String.Route.safeReturn(form.return)
|
||||
|
||||
guard let name = Self.displayName(form.name) else {
|
||||
return welcomePage(name: form.name, returnPath: returnPath, problem: Self.nameProblem, request: request, context: context)
|
||||
}
|
||||
|
||||
await sessions.update(try await accounts.rename(user, to: name))
|
||||
|
||||
return .redirect(to: returnPath, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles a request for the first-sign-in screen.
|
||||
@Sendable
|
||||
func welcome(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn, type: .found)
|
||||
}
|
||||
|
||||
return welcomePage(
|
||||
name: user.displayName,
|
||||
returnPath: .Route.safeReturn(request.uri.queryParameters.get("return")),
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the first-sign-in screen — with a `422` when it reports a problem.
|
||||
func welcomePage(
|
||||
name: String,
|
||||
returnPath: String,
|
||||
problem: String? = nil,
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
HTMLResponse(
|
||||
status: problem == nil ? .ok : .unprocessableContent,
|
||||
additionalHeaders: [.cacheControl: "no-store"]
|
||||
) { [analytics, assetVersion] in
|
||||
WelcomePage(
|
||||
displayName: name,
|
||||
returnPath: returnPath,
|
||||
problem: problem,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Opening a shop
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
/// Handles the open-a-shop form posted: opens the shop, and leads to its first listing.
|
||||
@Sendable
|
||||
func openShop(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .normal)
|
||||
}
|
||||
|
||||
let values = try await URLEncodedFormDecoder().decode([String: String].self, from: request, context: context)
|
||||
let form = ShopForm(values: values)
|
||||
|
||||
switch form.validated() {
|
||||
case .failure(let problems):
|
||||
return await shopPage(form, problems: problems.fields, request: request, context: context)
|
||||
case .success(let draft):
|
||||
do {
|
||||
_ = try await catalogue.openShop(draft, for: user)
|
||||
} catch ShopError.handleTaken {
|
||||
return await shopPage(form, problems: [.handle: "Another shop has that handle."], request: request, context: context)
|
||||
} catch ShopError.alreadyOwner {
|
||||
return .redirect(to: .Route.sellerDashboard, type: .normal)
|
||||
}
|
||||
|
||||
return .redirect(to: NewListingPage.path, type: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a request for the open-a-shop form; an account that owns a shop is led to its dashboard.
|
||||
@Sendable
|
||||
func shopForm(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard await sessions.user(for: request) != nil else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .found)
|
||||
}
|
||||
|
||||
guard await sessions.viewer(for: request)?.hasShop != true else {
|
||||
return .redirect(to: .Route.sellerDashboard, type: .found)
|
||||
}
|
||||
|
||||
return await shopPage(.init(), request: request, context: context)
|
||||
}
|
||||
|
||||
/// Renders the open-a-shop form — with a `422` when it reports problems.
|
||||
func shopPage(
|
||||
_ form: ShopForm,
|
||||
problems: [ShopForm.Field: String] = [:],
|
||||
request: Request,
|
||||
context: Context
|
||||
) async -> Response {
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return HTMLResponse(
|
||||
status: problems.isEmpty ? .ok : .unprocessableContent,
|
||||
additionalHeaders: .perViewer
|
||||
) { [analytics, assetVersion] in
|
||||
OpenShopPage(
|
||||
form: form,
|
||||
problems: problems,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static var nameProblem: String { "Enter a display name of 2 to 40 characters." }
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// A display name as posted, trimmed; or `nil` when it is too short or too long.
|
||||
static func displayName(
|
||||
_ name: String
|
||||
) -> String? {
|
||||
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
return (2...40).contains(name.count) ? name : nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// The fields a display name form posts.
|
||||
private struct NameForm: Decodable {
|
||||
let name: String
|
||||
let `return`: String?
|
||||
}
|
||||
|
||||
/// The field a passkey's revoke form posts.
|
||||
private struct RevokeForm: Decodable {
|
||||
let credential: String
|
||||
}
|
||||
Reference in new issue
Block a user