Implemented phase 6 for the POC prototype in the Website service.
This commit is contained in:
1 parent
256d22df40
commit
f2f445827b
40 files changed
+1906
-37
No files matched your search
@@ -18,6 +18,9 @@ public struct CatalogueController<Context: RequestContext> {
|
||||
/// The version token appended to the pages' asset URLs, or `nil` to leave them unversioned.
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository the events are read through, or `nil` to leave pickup out.
|
||||
private let collecting: CollectingRepository?
|
||||
|
||||
/// The repository the shops and listings are read through.
|
||||
private let repository: CatalogueRepository
|
||||
|
||||
@@ -30,16 +33,19 @@ public struct CatalogueController<Context: RequestContext> {
|
||||
/// - Parameters:
|
||||
/// - repository: the repository the shops and listings are read through.
|
||||
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
|
||||
/// - collecting: the repository the events are read through, or `nil` (the default) to leave pickup out.
|
||||
/// - 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(
|
||||
repository: CatalogueRepository,
|
||||
sessions: SessionStore? = nil,
|
||||
collecting: CollectingRepository? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.collecting = collecting
|
||||
self.repository = repository
|
||||
self.sessions = sessions
|
||||
}
|
||||
@@ -88,6 +94,13 @@ private extension CatalogueController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The upcoming events a shop trades at, soonest first.
|
||||
func events(
|
||||
tradedAtBy slug: String
|
||||
) async throws -> [Event] {
|
||||
try await collecting?.upcomingEvents().filter { $0.shops.contains { $0.slug == slug } } ?? []
|
||||
}
|
||||
|
||||
/// Handles a request for the grading guide.
|
||||
@Sendable
|
||||
func gradingGuide(
|
||||
@@ -115,10 +128,13 @@ private extension CatalogueController {
|
||||
throw HTTPError(.notFound)
|
||||
}
|
||||
|
||||
let related = try await repository
|
||||
let inShop = try await repository
|
||||
.listings(matching: .init(shop: listing.shop.slug))
|
||||
.filter { $0.slug != listing.slug }
|
||||
.prefix(Self.relatedCount)
|
||||
let related = inShop.prefix(Self.relatedCount)
|
||||
let pickups = try await events(tradedAtBy: listing.shop.slug)
|
||||
// Only a sold copy says how many others there are.
|
||||
let sameTitle = listing.isAvailable ? [] : try await repository.listings().filter { $0.title == listing.title }
|
||||
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
@@ -126,6 +142,8 @@ private extension CatalogueController {
|
||||
ListingPage(
|
||||
listing: listing,
|
||||
related: Array(related),
|
||||
pickups: pickups,
|
||||
otherCopies: (inShop.count { $0.title == listing.title }, sameTitle.count),
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
@@ -140,7 +158,16 @@ private extension CatalogueController {
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
let parameters = SearchParameters(query: request.uri.query)
|
||||
let matches = try await repository.listings(matching: parameters.filter)
|
||||
var filter = parameters.filter
|
||||
|
||||
// Pickup narrows the search to the shops trading at one event — or at any, for the rail's checkbox.
|
||||
if let pickup = parameters.value(.pickup) {
|
||||
filter.shops = try await collecting?.upcomingEvents()
|
||||
.filter { pickup == SearchParameters.anyEvent || $0.slug == pickup }
|
||||
.flatMap { $0.shops.map(\.slug) } ?? []
|
||||
}
|
||||
|
||||
let matches = try await repository.listings(matching: filter)
|
||||
let publishers = try await repository.publishers()
|
||||
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
@@ -170,6 +197,7 @@ private extension CatalogueController {
|
||||
let parameters = SearchParameters(query: request.uri.query)
|
||||
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
|
||||
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
|
||||
let pickups = try await events(tradedAtBy: shop.slug)
|
||||
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
@@ -179,6 +207,7 @@ private extension CatalogueController {
|
||||
inventory: inventory,
|
||||
matches: matches,
|
||||
parameters: parameters,
|
||||
pickups: pickups,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
|
||||
@@ -64,7 +64,11 @@ extension CollectingController: RouterController {
|
||||
routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant)
|
||||
routes.get(.init(.Route.series), use: seriesIndex)
|
||||
routes.get(.init("\(String.Route.series)/:slug"), use: series)
|
||||
routes.post(.init("\(String.Route.series)/:slug/own"), use: toggleOwned)
|
||||
routes.get(.init(.Route.compare), use: compare)
|
||||
routes.get(.init(.Route.collection), use: collection)
|
||||
routes.post(.init(.Route.collection), use: addBooks)
|
||||
routes.get(.init(.Route.events), use: events)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -240,12 +244,18 @@ private extension CollectingController {
|
||||
let listings = try await catalogue.listings().filter { $0.seriesSlug == slug }
|
||||
let issue = request.uri.queryParameters.get("issue", as: Int.self)
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
var owned: Set<Int>?
|
||||
|
||||
if let user = await sessions.user(for: request) {
|
||||
owned = try await collecting.ownedIssues(of: user)[slug] ?? []
|
||||
}
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
SeriesPage(
|
||||
series: series,
|
||||
listings: listings,
|
||||
issue: issue,
|
||||
owned: owned,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
@@ -277,8 +287,114 @@ private extension CollectingController {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Collection and events
|
||||
|
||||
private extension CollectingController {
|
||||
|
||||
/// Handles books added to the collection from its form.
|
||||
@Sendable
|
||||
func addBooks(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(BooksForm.self, from: request, context: context)
|
||||
|
||||
try await collecting.own(issues: CollectionPage.issues(in: form.issues), ofSeries: form.series, by: user)
|
||||
|
||||
return .redirect(to: .Route.collection, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles a request for the collection tracker.
|
||||
@Sendable
|
||||
func collection(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .found)
|
||||
}
|
||||
|
||||
let catalogue = try await collecting.allSeries()
|
||||
let owned = try await collecting.ownedIssues(of: user)
|
||||
let listings = try await self.catalogue.listings()
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
CollectionPage(
|
||||
catalogue: catalogue,
|
||||
owned: owned,
|
||||
listings: listings,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a request for the events.
|
||||
@Sendable
|
||||
func events(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
let events = try await collecting.upcomingEvents()
|
||||
let listings = try await catalogue.listings()
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
EventsPage(
|
||||
events: events,
|
||||
listings: listings,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the switch on an issue of a series: into the collection, or out of it.
|
||||
@Sendable
|
||||
func toggleOwned(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let slug = try context.parameters.require("slug")
|
||||
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.series(slug)), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(IssueForm.self, from: request, context: context)
|
||||
|
||||
if try await collecting.ownedIssues(of: user)[slug]?.contains(form.issue) == true {
|
||||
try await collecting.disown(issue: form.issue, ofSeries: slug, by: user)
|
||||
} else {
|
||||
try await collecting.own(issues: [form.issue], ofSeries: slug, by: user)
|
||||
}
|
||||
|
||||
return .redirect(to: .Route.series(slug), type: .normal)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// The fields the add-books form posts.
|
||||
private struct BooksForm: Decodable {
|
||||
let series: String
|
||||
let issues: String
|
||||
}
|
||||
|
||||
/// The field an issue's switch posts.
|
||||
private struct IssueForm: Decodable {
|
||||
let issue: Int
|
||||
}
|
||||
|
||||
/// The fields the add-a-want form posts.
|
||||
private struct WantForm: Decodable {
|
||||
let title: String
|
||||
|
||||
@@ -28,6 +28,9 @@ public struct PasskeyController<Context: RequestContext> {
|
||||
/// The origin the sign-in screen is served at, which passkeys are bound to.
|
||||
private let origin: String
|
||||
|
||||
/// The one-time recovery links a recovery ceremony answers to.
|
||||
private let recoveryLinks: RecoveryLinks
|
||||
|
||||
/// The store sessions are started in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
@@ -40,12 +43,15 @@ public struct PasskeyController<Context: RequestContext> {
|
||||
/// - accounts: the repository accounts and their passkeys are read and created through.
|
||||
/// - sessions: the store sessions are started in.
|
||||
/// - ceremonies: the ceremonies under way; a set of its own by default.
|
||||
/// - recoveryLinks: the one-time recovery links a recovery ceremony answers to; a set of its own by default.
|
||||
public init(
|
||||
origin: String,
|
||||
accounts: AccountRepository,
|
||||
sessions: SessionStore,
|
||||
ceremonies: PasskeyCeremonies = .init()
|
||||
ceremonies: PasskeyCeremonies = .init(),
|
||||
recoveryLinks: RecoveryLinks = .init()
|
||||
) {
|
||||
self.recoveryLinks = recoveryLinks
|
||||
self.accounts = accounts
|
||||
self.ceremonies = ceremonies
|
||||
self.isSecure = origin.hasPrefix("https://")
|
||||
@@ -136,6 +142,29 @@ private extension PasskeyController {
|
||||
|
||||
// A new account picks its display name before going on.
|
||||
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
|
||||
case .recovery(let token):
|
||||
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
|
||||
let credential = try await manager.finishRegistration(
|
||||
challenge: ceremony.challenge,
|
||||
credentialCreationData: answer.credential,
|
||||
confirmCredentialIDNotRegisteredYet: { [accounts] in
|
||||
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
|
||||
}
|
||||
)
|
||||
|
||||
// Used up only now: a prompt cancelled leaves the link good for another try.
|
||||
guard let owner = await recoveryLinks.redeem(token) else {
|
||||
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
|
||||
}
|
||||
|
||||
try await accounts.replacePasskeys(
|
||||
of: owner,
|
||||
credentialID: Self.base64URL(credential.id),
|
||||
publicKey: credential.publicKey,
|
||||
signCount: credential.signCount
|
||||
)
|
||||
|
||||
return try await signedIn(owner, returningTo: .Route.account)
|
||||
case .addition(let userID):
|
||||
guard let owner = await sessions.user(for: request), owner.id == userID else {
|
||||
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
|
||||
@@ -198,6 +227,21 @@ private extension PasskeyController {
|
||||
let start = try? await request.decode(as: Start.self, context: context)
|
||||
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
// From a recovery link: a new passkey, replacing the account's others.
|
||||
if let token = start?.recover {
|
||||
guard let owner = await recoveryLinks.user(for: token) else {
|
||||
return Self.refusal(.gone, "That recovery link has run out. Ask for a new one.")
|
||||
}
|
||||
|
||||
let options = manager.beginRegistration(user: .init(
|
||||
id: Array(UUID().uuidString.utf8),
|
||||
name: owner.email,
|
||||
displayName: owner.displayName
|
||||
))
|
||||
|
||||
return try await begin(.recovery(token: token), options: options, challenge: options.challenge, mode: "create")
|
||||
}
|
||||
|
||||
// From the account screen: another passkey for the account signed in.
|
||||
if start?.add == true {
|
||||
guard let owner = await sessions.user(for: request) else {
|
||||
@@ -343,6 +387,7 @@ private extension PasskeyController {
|
||||
private struct Start: Decodable {
|
||||
let email: String?
|
||||
let add: Bool?
|
||||
let recover: String?
|
||||
}
|
||||
|
||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||
|
||||
@@ -29,6 +29,9 @@ public struct SessionController<Context: RequestContext> {
|
||||
/// Whether the session cookie is limited to HTTPS.
|
||||
private let isSecure: Bool
|
||||
|
||||
/// The one-time recovery links handed out.
|
||||
private let recoveryLinks: RecoveryLinks
|
||||
|
||||
/// The store sessions are started and ended in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
@@ -39,6 +42,7 @@ public struct SessionController<Context: RequestContext> {
|
||||
/// - sessions: the store sessions are started and ended in.
|
||||
/// - accounts: the repository accounts are found and created through.
|
||||
/// - catalogue: the repository the visitor's intent is described from.
|
||||
/// - recoveryLinks: the one-time recovery links handed out; a set of its own by default.
|
||||
/// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
||||
@@ -46,6 +50,7 @@ public struct SessionController<Context: RequestContext> {
|
||||
sessions: SessionStore,
|
||||
accounts: AccountRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
recoveryLinks: RecoveryLinks = .init(),
|
||||
isSecure: Bool = false,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
@@ -55,6 +60,7 @@ public struct SessionController<Context: RequestContext> {
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.isSecure = isSecure
|
||||
self.recoveryLinks = recoveryLinks
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
@@ -81,6 +87,9 @@ extension SessionController: RouterController {
|
||||
.Session.signOut,
|
||||
use: signOut
|
||||
)
|
||||
routes.get(.init(.Route.recover), use: recover)
|
||||
routes.post(.init(.Route.recover), use: sendRecoveryLink)
|
||||
routes.get(.init("\(String.Route.recover)/:token"), use: followRecoveryLink)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -112,6 +121,19 @@ private extension SessionController {
|
||||
.split(separator: "/")
|
||||
.map(String.init)
|
||||
|
||||
guard let first = segments.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch first {
|
||||
case "messages": return "To read and send messages"
|
||||
case "wants": return "To keep a want list"
|
||||
case "collection": return "To track your collection"
|
||||
case "sell": return "To sell on CCN"
|
||||
case "account": return "To manage your account"
|
||||
default: break
|
||||
}
|
||||
|
||||
guard segments.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
@@ -155,6 +177,79 @@ private extension SessionController {
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a one-time recovery link followed: the way to a new passkey while it is good, and word that it ran out otherwise.
|
||||
@Sendable
|
||||
func followRecoveryLink(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let token = try context.parameters.require("token")
|
||||
|
||||
return recoveryPage(
|
||||
await recoveryLinks.user(for: token) == nil ? .expired : .renew(token: token),
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles a request for the recovery screen.
|
||||
@Sendable
|
||||
func recover(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
recoveryPage(.ask(problem: nil), request: request, context: context)
|
||||
}
|
||||
|
||||
/// Renders a step of the recovery, never stored.
|
||||
func recoveryPage(
|
||||
_ step: RecoverPage.Step,
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
let status: HTTPResponse.Status = switch step {
|
||||
case .ask(let problem) where problem != nil: .unprocessableContent
|
||||
case .expired: .gone
|
||||
default: .ok
|
||||
}
|
||||
|
||||
return HTMLResponse(
|
||||
status: status,
|
||||
additionalHeaders: [.cacheControl: "no-store"]
|
||||
) { [analytics, assetVersion] in
|
||||
RecoverPage(
|
||||
step: step,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a request for a recovery link.
|
||||
///
|
||||
/// **Stubbed**: with no mail to send, the link is shown on the screen — to anyone who asks. It must be mailed, and the screen say the
|
||||
/// same whether or not the email has an account, before anything real depends on it.
|
||||
@Sendable
|
||||
func sendRecoveryLink(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let form = try await URLEncodedFormDecoder().decode(RecoveryForm.self, from: request, context: context)
|
||||
|
||||
guard let email = NormalizeEmail()(form.email) else {
|
||||
return recoveryPage(.ask(problem: "That doesn't look like an email address."), request: request, context: context)
|
||||
}
|
||||
|
||||
var link: String?
|
||||
|
||||
if let user = try await accounts.existingAccount(email: email) {
|
||||
link = .Route.recover(token: await recoveryLinks.make(for: user))
|
||||
}
|
||||
|
||||
return recoveryPage(.sent(link: link), request: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
|
||||
@Sendable
|
||||
func show(
|
||||
@@ -237,3 +332,8 @@ private extension RouterPath {
|
||||
static let signOut: RouterPath = .init(.Route.signOut)
|
||||
}
|
||||
}
|
||||
|
||||
/// The field the recovery form posts.
|
||||
private struct RecoveryForm: Decodable {
|
||||
let email: String
|
||||
}
|
||||
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
|
||||
/// The repository listings and shops are read through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// The repository the events a shop trades at are read and set through, or `nil` to leave them out.
|
||||
private let collecting: CollectingRepository?
|
||||
|
||||
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
|
||||
private let onCatalogueChange: @Sendable () async -> Void
|
||||
|
||||
@@ -36,6 +39,7 @@ public struct TradeController<Context: RequestContext> {
|
||||
/// - trade: the repository conversations, offers, and sales are read and written through.
|
||||
/// - catalogue: the repository listings and shops are read through.
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - collecting: the repository the events a shop trades at are read and set through, or `nil` (the default) to leave them out.
|
||||
/// - 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.
|
||||
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
||||
@@ -43,6 +47,7 @@ public struct TradeController<Context: RequestContext> {
|
||||
trade: TradeRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
sessions: SessionStore,
|
||||
collecting: CollectingRepository? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
||||
@@ -51,6 +56,7 @@ public struct TradeController<Context: RequestContext> {
|
||||
self.onCatalogueChange = onCatalogueChange
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.collecting = collecting
|
||||
self.sessions = sessions
|
||||
self.trade = trade
|
||||
}
|
||||
@@ -78,6 +84,7 @@ extension TradeController: RouterController {
|
||||
routes.post(.Trade.sale, use: takeStep)
|
||||
routes.get(.Trade.dashboard, use: dashboard)
|
||||
routes.post(.Trade.answer, use: answer)
|
||||
routes.post(.init("\(String.Route.sellerDashboard)/events"), use: setEvents)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -429,6 +436,26 @@ private extension TradeController {
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles the events a seller ticked on the dashboard. A form with no box ticked posts no field at all.
|
||||
@Sendable
|
||||
func setEvents(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let (user, _) = await account(of: request) else {
|
||||
return .redirect(to: .Route.signIn, type: .normal)
|
||||
}
|
||||
|
||||
let body = try await request.body.collect(upTo: 16 * 1_024)
|
||||
let slugs = SearchParameters(query: String(buffer: body)).items
|
||||
.filter { $0.name == "events" }
|
||||
.map(\.value)
|
||||
|
||||
try await collecting?.setEvents(slugs, forShopOwnedBy: user)
|
||||
|
||||
return .redirect(to: .Route.sellerDashboard, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
|
||||
@Sendable
|
||||
func dashboard(
|
||||
@@ -446,6 +473,7 @@ private extension TradeController {
|
||||
let offers = try await trade.openOffers(for: user)
|
||||
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id }
|
||||
let listings = try await catalogue.listings(matching: .init(shop: shop.slug))
|
||||
let events = try await collecting?.upcomingEvents() ?? []
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
SellerDashboardPage(
|
||||
@@ -453,6 +481,7 @@ private extension TradeController {
|
||||
offers: offers,
|
||||
sales: sales,
|
||||
listings: listings,
|
||||
events: events,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
|
||||
@@ -90,6 +90,8 @@ extension PasskeyCeremonies {
|
||||
case addition(userID: UUID)
|
||||
/// Signs in with a passkey registered earlier.
|
||||
case authentication
|
||||
/// Replaces the passkeys of the account a one-time recovery link names.
|
||||
case recovery(token: String)
|
||||
/// Creates the account of the given email, with its first passkey.
|
||||
case registration(email: String)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
/// The one-time recovery links handed out: each an unguessable token naming an account, good once, for an hour.
|
||||
public actor RecoveryLinks {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// How long a link works: an hour.
|
||||
static let lifetime: TimeInterval = 3_600
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The clock expiries are judged by.
|
||||
private let now: @Sendable () -> Date
|
||||
|
||||
/// The links still good, by token.
|
||||
private var links: [String: (user: User, expiresAt: Date)] = [:]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an empty set of links.
|
||||
/// - Parameter now: the clock expiries are judged by; the system's by default.
|
||||
public init(
|
||||
now: @escaping @Sendable () -> Date = { .init() }
|
||||
) {
|
||||
self.now = now
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Makes a link for an account, ending any it had before.
|
||||
/// - Returns: the link's token.
|
||||
func make(
|
||||
for user: User
|
||||
) -> String {
|
||||
let token = "\(UUID().uuidString)\(UUID().uuidString)"
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.lowercased()
|
||||
|
||||
links = links.filter { $0.value.user.id != user.id && $0.value.expiresAt > now() }
|
||||
links[token] = (user, now().addingTimeInterval(Self.lifetime))
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/// The account a link still good names, without using the link up.
|
||||
func user(
|
||||
for token: String
|
||||
) -> User? {
|
||||
guard let link = links[token], link.expiresAt > now() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return link.user
|
||||
}
|
||||
|
||||
/// Uses a link up.
|
||||
/// - Returns: the account it named, or `nil` when it was no longer good.
|
||||
func redeem(
|
||||
_ token: String
|
||||
) -> User? {
|
||||
defer {
|
||||
links[token] = nil
|
||||
}
|
||||
|
||||
return user(for: token)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user