254 lines
7.4 KiB
Swift
254 lines
7.4 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|