Files
ccn/Services/Website/Sources/Library/Public/Controllers/AccountController.swift
T

333 lines
11 KiB
Swift

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
}