184 lines
6.5 KiB
Swift
184 lines
6.5 KiB
Swift
import Elementary
|
|
import Foundation
|
|
import Infrastructure
|
|
import Persistence
|
|
|
|
/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back.
|
|
///
|
|
/// A real passkey creates an account, or signs in one created with a passkey — see ``PasskeyController``. The demo accounts have none, so
|
|
/// the stubbed demo sign-in stays beside it — see ``SessionController``.
|
|
struct SignInPage {
|
|
|
|
// MARK: Properties
|
|
|
|
let analytics: Analytics?
|
|
let assetVersion: String?
|
|
let locale: Locale
|
|
|
|
/// What went wrong with the last attempt, or `nil` when nothing did.
|
|
let error: String?
|
|
|
|
/// What the visitor was about to do, or `nil` when they came here directly.
|
|
let intent: String?
|
|
|
|
/// The path the visitor returns to, signed in or not.
|
|
let returnPath: String
|
|
|
|
// MARK: Initializers
|
|
|
|
/// Creates the sign-in screen.
|
|
/// - Parameters:
|
|
/// - returnPath: the path the visitor returns to, signed in or not.
|
|
/// - intent: what the visitor was about to do, or `nil` (the default) when they came here directly.
|
|
/// - error: what went wrong with the last attempt, or `nil` (the default) when nothing did.
|
|
/// - 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(
|
|
returnPath: String,
|
|
intent: String? = nil,
|
|
error: String? = nil,
|
|
assetVersion: String? = nil,
|
|
analytics: Analytics? = nil
|
|
) {
|
|
self.analytics = analytics
|
|
self.assetVersion = assetVersion
|
|
self.error = error
|
|
self.intent = intent
|
|
self.locale = .init(identifier: "en")
|
|
self.returnPath = returnPath
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Page
|
|
|
|
extension SignInPage: Page {
|
|
|
|
// MARK: Computed
|
|
|
|
var content: some HTML {
|
|
div(.class("gate")) {
|
|
header(.class("gate__bar")) {
|
|
a(
|
|
.class("gate__close"),
|
|
.href(returnPath),
|
|
.custom(name: "aria-label", value: "Close and go back")
|
|
) {
|
|
Icon(.close)
|
|
}
|
|
span(.class("gate__wordmark")) {
|
|
"CCN"
|
|
}
|
|
}
|
|
main(.class("gate__main")) {
|
|
div(.class("gate__intro")) {
|
|
if let intent {
|
|
p(.class("type-eyebrow")) {
|
|
intent
|
|
}
|
|
}
|
|
h1(.class("type-h1")) {
|
|
"Sign in or join"
|
|
}
|
|
p(.class("gate__lead")) {
|
|
"No password. Your device confirms it's you."
|
|
}
|
|
}
|
|
form(
|
|
.class("gate__form"),
|
|
.action(.Route.signIn),
|
|
.method(.post),
|
|
.custom(name: "data-passkey-form", value: "")
|
|
) {
|
|
input(
|
|
.type(.hidden),
|
|
.name("return"),
|
|
.value(returnPath)
|
|
)
|
|
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
|
|
button(
|
|
.class("button button--primary button--xl button--block"),
|
|
.type(.button),
|
|
.hidden,
|
|
.custom(name: "data-passkey", value: "")
|
|
) {
|
|
Icon(.key, size: 18)
|
|
"Continue with passkey"
|
|
}
|
|
p(
|
|
.class("field__error"),
|
|
.custom(name: "role", value: "alert"),
|
|
.custom(name: "data-passkey-status", value: "")
|
|
) {}
|
|
div(.class("field")) {
|
|
label(
|
|
.class("field__label"),
|
|
.for("email")
|
|
) {
|
|
"New here, or on a new device? Enter your email"
|
|
}
|
|
input(
|
|
.class("input input--lg"),
|
|
.id("email"),
|
|
.type(.email),
|
|
.name("email"),
|
|
.placeholder("you@example.com"),
|
|
.custom(name: "autocomplete", value: "username webauthn")
|
|
)
|
|
.attributes(
|
|
.custom(name: "aria-invalid", value: "true"),
|
|
.custom(name: "aria-describedby", value: "email-error"),
|
|
when: error != nil
|
|
)
|
|
if let error {
|
|
p(
|
|
.class("field__error"),
|
|
.id("email-error")
|
|
) {
|
|
error
|
|
}
|
|
}
|
|
p(.class("field__hint")) {
|
|
"Unknown email? We create your account and passkey in the same step — nothing else to fill in."
|
|
}
|
|
}
|
|
div(.class("notice notice--warn gate__demo")) {
|
|
p {
|
|
"Prototype: the demo accounts have no passkey. The demo sign-in trusts the email above — or signs in the demo buyer, \(AccountRepository.demoEmail), without one. To sell, use joost@kestrelrow.example."
|
|
}
|
|
button(
|
|
.class("button button--outline button--block"),
|
|
.type(.submit)
|
|
) {
|
|
"Demo sign-in, without a passkey"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Kept out of search results: the screen is a step, not a destination.
|
|
@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 {
|
|
"Sign in or join — CCN"
|
|
}
|
|
|
|
}
|