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: "") ) {} PasskeyUnsupported() 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" } } } p(.class("gate__recover")) { a(.href(.Route.recover)) { "Lost access to your passkeys?" } } } } } /// 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" } }