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." } } } } } }