import Elementary import Foundation import Infrastructure import Persistence /// The account: the display name, the passkeys that sign it in, and the way out. struct AccountPage { // MARK: Properties let analytics: Analytics? let assetVersion: String? let locale: Locale /// The moment stamps are read relative to. let now: Date /// The account's passkeys, the oldest first, with when each was created. let passkeys: [(passkey: Passkey, createdAt: Date)] /// What is wrong with the display name posted, or `nil` when nothing is. let problem: String? /// The account shown. let user: User /// The signed-in viewer. let viewer: Viewer? // MARK: Initializers /// Creates the account screen. /// - Parameters: /// - user: the account shown. /// - passkeys: the account's passkeys, the oldest first, with when each was created. /// - problem: what is wrong with the display name posted, or `nil` (the default) when nothing is. /// - viewer: the signed-in viewer. /// - now: the moment stamps are read relative to; the present by default. /// - 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( user: User, passkeys: [(passkey: Passkey, createdAt: Date)], problem: String? = nil, viewer: Viewer? = nil, now: Date = .init(), assetVersion: String? = nil, analytics: Analytics? = nil ) { self.analytics = analytics self.assetVersion = assetVersion self.locale = .init(identifier: "en") self.now = now self.passkeys = passkeys self.problem = problem self.user = user self.viewer = viewer } } // MARK: - Page extension AccountPage: Page { // MARK: Computed var content: some HTML { SiteLayout(viewer: viewer) { div(.class("page account")) { aside(.class("account__rail")) { p(.class("type-eyebrow")) { "Account" } h1(.class("type-panel")) { user.displayName } nav( .class("account__nav"), .custom(name: "aria-label", value: "Account sections") ) { a(.href("#profile")) { "Profile" } a(.href("#passkeys")) { "Passkeys" } a(.href(.Route.wants)) { "Want list" } a(.href(.Route.collection)) { "Collection" } a(.href(.Route.messages)) { "Messages" } a(.href(viewer?.hasShop == true ? .Route.sellerDashboard : .Route.openShop)) { viewer?.hasShop == true ? "My shop" : "Open a shop" } } } div(.class("account__main")) { section( .class("card account__section"), .id("profile") ) { h2(.class("type-panel")) { "Profile" } form( .class("account__form"), .action("\(String.Route.account)/name"), .method(.post) ) { TextField( name: "name", title: "Display name", value: user.displayName, hint: "How sellers and buyers see you. Signed in as \(user.email).", problem: problem, maxLength: 40 ) button( .class("button button--dark"), .type(.submit) ) { "Save" } } } section( .class("card account__section"), .id("passkeys"), .custom(name: "data-passkey-form", value: "") ) { div(.class("section-head")) { h2(.class("type-panel")) { "Passkeys" } // Shown by `js/passkey.js` once the browser is known to do WebAuthn. button( .class("button button--primary button--sm"), .type(.button), .hidden, .custom(name: "data-passkey", value: "add") ) { Icon(.key, size: 15) "Add a passkey" } } p(.class("type-body-sm account__text")) { "Any of these signs you in. Keep at least two on different devices so a lost phone never locks you out. Revoking one takes effect immediately." } p( .class("field__error"), .custom(name: "role", value: "alert"), .custom(name: "data-passkey-status", value: "") ) {} if passkeys.isEmpty { p(.class("notice")) { "No passkey yet: this account signs in through the prototype's demo sign-in." } } else { ul(.class("rows")) { ForEach(Array(passkeys.enumerated())) { index, entry in li(.class("rows__row rows__row--passkey")) { Icon(.key) span { strong { "Passkey \(index + 1)" } span(.class("type-small")) { " · created \(entry.createdAt.stamp(relativeTo: now))" } } form( .action("\(String.Route.account)/passkeys/revoke"), .method(.post) ) { input( .type(.hidden), .name("credential"), .value(entry.passkey.credentialID) ) button( .class("button button--sm"), .type(.submit) ) { "Revoke" } } } } } } } section(.class("card account__section")) { h2(.class("type-panel")) { "Sign out" } form( .action(.Route.signOut), .method(.post) ) { button( .class("button button--outline"), .type(.submit) ) { "Sign out of this device" } } } } } } } @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.trade] } var title: String { "Account — CCN" } }