Files
ccn/Services/Website/Sources/Library/Internal/Pages/RecoverPage.swift
T

253 lines
7.4 KiB
Swift
Raw Normal View History

import Elementary
import Foundation
import Infrastructure
/// The recovery of an account whose every passkey is lost: ask for a one-time link, then — from the link — set up a new passkey.
///
/// Drawn bare, like the sign-in. The prototype sends no mail, so it shows the link it would have sent.
struct RecoverPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// What the screen shows.
let step: Step
// MARK: Initializers
/// Creates the recovery screen.
/// - Parameters:
/// - step: what the screen shows.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
step: Step,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = .init(identifier: "en")
self.step = step
}
}
// MARK: - Enumerations
extension RecoverPage {
/// A step of the recovery.
enum Step: Sendable, Equatable {
/// Asking for the account's email, with what was wrong with the last one given.
case ask(problem: String?)
/// The link is on its way — here, on the screen, when the email has an account.
case sent(link: String?)
/// The link was followed: a new passkey can be set up. Carries the link's token.
case renew(token: String)
/// The link was used, ran out, or never existed.
case expired
}
}
// MARK: - Page
extension RecoverPage: Page {
// MARK: Computed
var content: some HTML {
div(.class("gate")) {
header(.class("gate__bar")) {
a(
.class("gate__close"),
.href(.Route.signIn),
.custom(name: "aria-label", value: "Back to sign in")
) {
Icon(.close)
}
span(.class("gate__wordmark")) {
"CCN"
}
}
main(.class("gate__main")) {
switch step {
case .ask(let problem):
ask(problem)
case .sent(let link):
sent(link)
case .renew(let token):
renew(token)
case .expired:
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"That link has run out"
}
p(.class("gate__lead")) {
"A recovery link works once, for an hour. Ask for a new one."
}
}
a(
.class("button button--primary button--xl button--block"),
.href(.Route.recover)
) {
"Send a new link"
}
}
}
}
}
@HTMLBuilder
var metadata: some HTML {
meta(
.name("robots"),
.content("noindex")
)
siteMetadata
}
var scripts: [any Asset] {
[StaticFile.shared, StaticFile.passkey]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.signIn]
}
var title: String {
"Recover your account — CCN"
}
}
// MARK: - Steps
private extension RecoverPage {
@HTMLBuilder
func ask(
_ problem: String?
) -> some HTML {
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"Recover your account"
}
p(.class("gate__lead")) {
"We'll email you a one-time link to set up a new passkey. Your old passkeys are removed when you do."
}
}
form(
.class("gate__form"),
.action(.Route.recover),
.method(.post)
) {
div(.class("field")) {
label(
.class("field__label"),
.for("email")
) {
"Account email"
}
input(
.class("input input--lg"),
.id("email"),
.type(.email),
.name("email"),
.required,
.placeholder("you@example.com"),
.custom(name: "autocomplete", value: "email")
)
FieldNote(name: "email", hint: nil, problem: problem)
}
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
) {
"Send recovery link"
}
}
p(.class("notice")) {
"Still have a passkey on another device? "
a(.href(.Route.signIn)) {
"Go back"
}
" and press Continue — your device will offer to use it."
}
}
@HTMLBuilder
func renew(
_ token: String
) -> some HTML {
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"Set up a new passkey"
}
p(.class("gate__lead")) {
"This replaces every passkey on the account, and signs you in."
}
}
div(
.class("gate__form"),
.custom(name: "data-passkey-form", value: "")
) {
input(
.type(.hidden),
.name("recover"),
.value(token)
)
button(
.class("button button--primary button--xl button--block"),
.type(.button),
.hidden,
.custom(name: "data-passkey", value: "recover")
) {
Icon(.key, size: 18)
"Create a new passkey"
}
p(
.class("field__error"),
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
PasskeyUnsupported()
}
}
@HTMLBuilder
func sent(
_ link: String?
) -> some HTML {
div(.class("gate__intro")) {
h1(.class("type-h1")) {
"Check your email"
}
p(.class("gate__lead")) {
"If that email has an account, a one-time link is on its way. It works once, for an hour."
}
}
div(.class("notice notice--warn gate__demo")) {
p {
"Prototype: CCN sends no mail yet, so the link appears here instead — which also means anyone who knows an email can recover its account. The design's 24-hour hold, and the cancel from another device, are not built."
}
if let link {
a(
.class("button button--outline button--block"),
.href(link)
) {
"Open the recovery link"
}
} else {
p {
strong {
"No account has that email, so no link was made."
}
}
}
}
}
}