2026-09-21 11:44:58 +02:00
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.
///
2026-09-21 12:55:35 +02:00
/// 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``.
2026-09-21 11:44:58 +02:00
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 ),
2026-09-21 12:55:35 +02:00
. method (. post ),
. custom ( name : "data-passkey-form" , value : "" )
2026-09-21 11:44:58 +02:00
) {
input (
. type (. hidden ),
. name ( "return" ),
. value ( returnPath )
)
2026-09-21 12:55:35 +02:00
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
2026-09-21 11:44:58 +02:00
button (
. class ( "button button--primary button--xl button--block" ),
2026-09-21 12:55:35 +02:00
. type (. button ),
. hidden ,
. custom ( name : "data-passkey" , value : "" )
2026-09-21 11:44:58 +02:00
) {
Icon (. key , size : 18 )
"Continue with passkey"
}
2026-09-21 12:55:35 +02:00
p (
. class ( "field__error" ),
. custom ( name : "role" , value : "alert" ),
. custom ( name : "data-passkey-status" , value : "" )
) {}
2026-09-21 11:44:58 +02:00
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."
}
}
2026-09-21 12:55:35 +02:00
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"
}
}
2026-09-21 11:44:58 +02:00
}
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 ] {
2026-09-21 12:55:35 +02:00
[ StaticFile . shared , StaticFile . passkey ]
2026-09-21 11:44:58 +02:00
}
var stylesheets : [ any Asset ] {
[ StaticFile . shared , StaticFile . signIn ]
}
var title : String {
"Sign in or join — CCN"
}
}