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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user