Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -0,0 +1,236 @@
|
||||
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.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"
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user