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
@@ -0,0 +1,253 @@
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user