Implemented phase 2 for the POC prototype in the Website service.
This commit is contained in:
1 parent
09c7b8a1a7
commit
43dcc03da6
28 files changed
+1468
-65
No files matched your search
@@ -70,6 +70,7 @@ func application(
|
||||
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
|
||||
logLevel: reader.logLevel,
|
||||
probe: Probe(fluent: fluent),
|
||||
accounts: AccountRepository(fluent: fluent),
|
||||
repository: repository,
|
||||
listings: await homeListings(
|
||||
from: repository,
|
||||
@@ -182,6 +183,7 @@ private func logger(
|
||||
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
|
||||
/// - logLevel: the level the request-logging middleware logs at.
|
||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||
/// - accounts: the repository the `SessionController` finds and creates accounts through.
|
||||
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
|
||||
/// - listings: the listings the landing page shows in its rows.
|
||||
/// - Returns: the configured router.
|
||||
@@ -197,6 +199,7 @@ private func router(
|
||||
siteOrigin: String?,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe,
|
||||
accounts: AccountRepository,
|
||||
repository: CatalogueRepository,
|
||||
listings: HomeListings
|
||||
) -> Router<AppRequestContext> {
|
||||
@@ -206,6 +209,7 @@ private func router(
|
||||
context: AppRequestContext.self,
|
||||
options: .autoGenerateHeadEndpoints
|
||||
)
|
||||
let sessions = SessionStore()
|
||||
|
||||
router.addMiddleware {
|
||||
LogRequestsMiddleware(logLevel)
|
||||
@@ -235,10 +239,21 @@ private func router(
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings
|
||||
listings: listings,
|
||||
sessions: sessions
|
||||
)
|
||||
CatalogueController<AppRequestContext>(
|
||||
repository: repository,
|
||||
sessions: sessions,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
SessionController<AppRequestContext>(
|
||||
sessions: sessions,
|
||||
accounts: accounts,
|
||||
catalogue: repository,
|
||||
// Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable.
|
||||
isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"),
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
|
||||
@@ -56,6 +56,8 @@ extension Icon {
|
||||
case bookmark
|
||||
/// A check mark.
|
||||
case check
|
||||
/// A cross.
|
||||
case close
|
||||
/// A page of text.
|
||||
case document
|
||||
/// Filter lines.
|
||||
@@ -85,6 +87,7 @@ extension Icon.Glyph {
|
||||
case .bell: #"<path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4z"></path><path d="M10 21h4"></path>"#
|
||||
case .bookmark: #"<path d="M6 3h12v18l-6-4-6 4z"></path>"#
|
||||
case .check: #"<path d="m5 12 5 5 9-10"></path>"#
|
||||
case .close: #"<path d="M6 6l12 12M18 6 6 18"></path>"#
|
||||
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
|
||||
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
|
||||
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
|
||||
@@ -100,7 +103,7 @@ extension Icon.Glyph {
|
||||
var strokeWidth: String {
|
||||
switch self {
|
||||
case .check: "3.5"
|
||||
case .filter, .key, .search, .sort: "2.2"
|
||||
case .close, .filter, .key, .search, .sort: "2.2"
|
||||
default: "2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,12 +218,25 @@ private extension SiteHeader {
|
||||
)
|
||||
) {
|
||||
span(.class("from-desktop")) {
|
||||
viewer.displayName
|
||||
viewer.firstName
|
||||
}
|
||||
span(.class("site-header__avatar")) {
|
||||
viewer.initials
|
||||
}
|
||||
}
|
||||
// Not in the design, whose sign-out lives on the account screen the prototype leaves out.
|
||||
form(
|
||||
.class("site-header__sign-out"),
|
||||
.action(.Route.signOut),
|
||||
.method(.post)
|
||||
) {
|
||||
button(
|
||||
.class("site-header__text-link"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Sign out"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon link with a count badge, omitted at zero.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Elementary
|
||||
|
||||
/// An action the prototype shows but does not build: a button that is visibly, and audibly, unavailable.
|
||||
struct Unavailable: HTML {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The action's name.
|
||||
let label: String
|
||||
|
||||
/// A modifier class sizing or styling the button, or `nil` for none.
|
||||
let modifier: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an unavailable action.
|
||||
/// - Parameters:
|
||||
/// - label: the action's name.
|
||||
/// - modifier: a modifier class sizing or styling the button, or `nil` (the default) for none.
|
||||
init(
|
||||
_ label: String,
|
||||
modifier: String? = nil
|
||||
) {
|
||||
self.label = label
|
||||
self.modifier = modifier
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var body: some HTML {
|
||||
button(
|
||||
.class(["button", modifier].compactMap(\.self).joined(separator: " ")),
|
||||
.type(.button),
|
||||
.disabled,
|
||||
.title("Not part of the prototype")
|
||||
) {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import HTTPTypes
|
||||
|
||||
extension HTTPFields {
|
||||
/// The headers of a page rendered for its viewer: kept out of shared caches, and revalidated before reuse.
|
||||
static let perViewer: Self = [
|
||||
.cacheControl: "private, no-cache"
|
||||
]
|
||||
}
|
||||
@@ -33,11 +33,34 @@ extension String {
|
||||
static let series = "/series"
|
||||
/// The passkey sign-in screen.
|
||||
static let signIn = "/sign-in"
|
||||
/// The path a sign-out is posted to.
|
||||
static let signOut = "/sign-out"
|
||||
/// The terms of service.
|
||||
static let terms = "/terms"
|
||||
/// The want list.
|
||||
static let wants = "/wants"
|
||||
|
||||
/// The path buying a listing outright starts at.
|
||||
/// - Parameter slug: the listing's URL slug.
|
||||
static func buy(
|
||||
_ slug: String
|
||||
) -> String {
|
||||
"\(listing(slug))/buy"
|
||||
}
|
||||
|
||||
/// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken.
|
||||
/// - Parameters:
|
||||
/// - path: the action's own path.
|
||||
/// - origin: the path of the screen the action is taken on.
|
||||
/// - viewer: the signed-in viewer, or `nil` when signed out.
|
||||
static func gated(
|
||||
_ path: String,
|
||||
from origin: String,
|
||||
viewer: Viewer?
|
||||
) -> String {
|
||||
viewer == nil ? signIn(returningTo: origin) : path
|
||||
}
|
||||
|
||||
/// The path of a listing's detail screen.
|
||||
/// - Parameter slug: the listing's URL slug.
|
||||
static func listing(
|
||||
@@ -46,6 +69,30 @@ extension String {
|
||||
"/listing/\(slug)"
|
||||
}
|
||||
|
||||
/// The path of a new message thread about a listing.
|
||||
/// - Parameter slug: the listing's URL slug.
|
||||
static func message(
|
||||
listing slug: String
|
||||
) -> String {
|
||||
"\(messages)/new?listing=\(slug)"
|
||||
}
|
||||
|
||||
/// The path of a new message thread with a shop.
|
||||
/// - Parameter slug: the shop's URL slug.
|
||||
static func message(
|
||||
shop slug: String
|
||||
) -> String {
|
||||
"\(messages)/new?shop=\(slug)"
|
||||
}
|
||||
|
||||
/// The path of the offer sheet for a listing.
|
||||
/// - Parameter slug: the listing's URL slug.
|
||||
static func offer(
|
||||
_ slug: String
|
||||
) -> String {
|
||||
"\(listing(slug))/offer"
|
||||
}
|
||||
|
||||
/// The path of a shop's public screen.
|
||||
/// - Parameter slug: the shop's URL slug.
|
||||
static func shop(
|
||||
@@ -54,6 +101,27 @@ extension String {
|
||||
"/shop/\(slug)"
|
||||
}
|
||||
|
||||
/// The path to return to after signing in: the one asked for when it stays on this site, else the home screen.
|
||||
///
|
||||
/// Only a rooted path is followed. Anything else — another origin, a scheme-relative `//host`, a backslash a browser reads as a slash —
|
||||
/// would turn the sign-in into an open redirect.
|
||||
/// - Parameter path: the path asked for, as the `return` parameter carried it.
|
||||
static func safeReturn(
|
||||
_ path: String?
|
||||
) -> String {
|
||||
guard
|
||||
let path,
|
||||
path.hasPrefix("/"),
|
||||
!path.hasPrefix("//"),
|
||||
!path.contains("\\"),
|
||||
!path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control })
|
||||
else {
|
||||
return home
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
/// The path of the sign-in screen, returning to the given path afterwards.
|
||||
/// - Parameter path: the path the visitor's gated action started from.
|
||||
static func signIn(
|
||||
|
||||
@@ -24,6 +24,9 @@ struct IndexPage {
|
||||
/// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them.
|
||||
let siteOrigin: String?
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
|
||||
@@ -36,13 +39,16 @@ struct IndexPage {
|
||||
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
/// - listings: the listings the page shows in its rows; none by default.
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
listings: HomeListings = .init()
|
||||
listings: HomeListings = .init(),
|
||||
viewer: Viewer? = nil
|
||||
) {
|
||||
self.viewer = viewer
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.listings = listings
|
||||
@@ -90,7 +96,7 @@ extension IndexPage: Page {
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page home")) {
|
||||
section(.class("hero")) {
|
||||
div(.class("hero__text")) {
|
||||
|
||||
@@ -18,20 +18,26 @@ struct ListingPage {
|
||||
/// Other listings from the same shop.
|
||||
let related: [Listing]
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the detail screen of a listing.
|
||||
/// - Parameters:
|
||||
/// - listing: the listing shown.
|
||||
/// - related: other listings from the same shop.
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||
/// - 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(
|
||||
listing: Listing,
|
||||
related: [Listing],
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.viewer = viewer
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.listing = listing
|
||||
@@ -48,7 +54,10 @@ extension ListingPage: Page {
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(active: listing.kind) {
|
||||
SiteLayout(
|
||||
active: listing.kind,
|
||||
viewer: viewer
|
||||
) {
|
||||
div(.class("page listing")) {
|
||||
breadcrumb
|
||||
div(.class("listing__body")) {
|
||||
@@ -201,48 +210,54 @@ private extension ListingPage {
|
||||
listing.acceptsOffers ? " · accepts offers" : ""
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("button button--sm"),
|
||||
.href(signIn)
|
||||
) {
|
||||
Icon(.bookmark, size: 16)
|
||||
"Save"
|
||||
if viewer == nil {
|
||||
a(
|
||||
.class("button button--sm"),
|
||||
.href(.Route.signIn(returningTo: listing.path))
|
||||
) {
|
||||
Icon(.bookmark, size: 16)
|
||||
"Save"
|
||||
}
|
||||
} else {
|
||||
Unavailable("Save", modifier: "button--sm")
|
||||
}
|
||||
}
|
||||
div(.class("buy-box__actions")) {
|
||||
a(
|
||||
.class("button button--primary button--lg"),
|
||||
.href(signIn)
|
||||
.href(gated(.Route.buy(listing.slug)))
|
||||
) {
|
||||
"Buy now"
|
||||
}
|
||||
if listing.acceptsOffers {
|
||||
a(
|
||||
.class("button button--outline button--lg"),
|
||||
.href(signIn)
|
||||
.href(gated(.Route.offer(listing.slug)))
|
||||
) {
|
||||
"Make an offer"
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("button button--bare button--lg"),
|
||||
.href(signIn)
|
||||
.href(gated(.Route.message(listing: listing.slug)))
|
||||
) {
|
||||
Icon(.message, size: 16)
|
||||
"Message seller"
|
||||
}
|
||||
}
|
||||
p(.class("gate-note")) {
|
||||
Icon(.lock, size: 16)
|
||||
span {
|
||||
"Buying, offers, saving and messages need a signed-in account. "
|
||||
a(
|
||||
.class("gate-note__link"),
|
||||
.href(signIn)
|
||||
) {
|
||||
"Sign in or join with a passkey"
|
||||
if viewer == nil {
|
||||
p(.class("gate-note")) {
|
||||
Icon(.lock, size: 16)
|
||||
span {
|
||||
"Buying, offers, saving and messages need a signed-in account. "
|
||||
a(
|
||||
.class("gate-note__link"),
|
||||
.href(.Route.signIn(returningTo: listing.path))
|
||||
) {
|
||||
"Sign in or join with a passkey"
|
||||
}
|
||||
" — browsing stays open to everyone."
|
||||
}
|
||||
" — browsing stays open to everyone."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,9 +317,13 @@ private extension ListingPage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-in screen, returning to this listing.
|
||||
var signIn: String {
|
||||
.Route.signIn(returningTo: listing.path)
|
||||
// MARK: Methods
|
||||
|
||||
/// The path a gated action on this listing leads to.
|
||||
func gated(
|
||||
_ path: String
|
||||
) -> String {
|
||||
.Route.gated(path, from: listing.path, viewer: viewer)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,6 +30,9 @@ struct SearchPage {
|
||||
/// The number of shops the matching listings come from.
|
||||
let shopCount: Int
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the search results screen for a search and the listings matching it.
|
||||
@@ -37,15 +40,18 @@ struct SearchPage {
|
||||
/// - parameters: the search the screen answers.
|
||||
/// - matches: every listing matching the search; the screen shows the page the parameters ask for.
|
||||
/// - publishers: every publisher a listing can be filtered by.
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||
/// - 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(
|
||||
parameters: SearchParameters,
|
||||
matches: [Listing],
|
||||
publishers: [String],
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.viewer = viewer
|
||||
let slice = Pagination.slice(
|
||||
matches,
|
||||
page: parameters.page,
|
||||
@@ -82,7 +88,8 @@ extension SearchPage: Page {
|
||||
var content: some HTML {
|
||||
SiteLayout(
|
||||
active: formats.count == 1 ? formats.first : nil,
|
||||
query: parameters.value(.query)
|
||||
query: parameters.value(.query),
|
||||
viewer: viewer
|
||||
) {
|
||||
div(.class("page search")) {
|
||||
div(.class("search__head")) {
|
||||
|
||||
@@ -27,6 +27,9 @@ struct ShopPage {
|
||||
/// The shop shown.
|
||||
let shop: Shop
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the public screen of a shop.
|
||||
@@ -35,6 +38,7 @@ struct ShopPage {
|
||||
/// - inventory: every listing of the shop, counted per format on the tabs.
|
||||
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
|
||||
/// - parameters: the tab, quick filters, and sort order applied.
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||
/// - 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(
|
||||
@@ -42,9 +46,11 @@ struct ShopPage {
|
||||
inventory: [Listing],
|
||||
matches: [Listing],
|
||||
parameters: SearchParameters,
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.viewer = viewer
|
||||
let slice = Pagination.slice(
|
||||
matches,
|
||||
page: parameters.page,
|
||||
@@ -78,7 +84,7 @@ extension ShopPage: Page {
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page shop")) {
|
||||
section(.class("shop__head")) {
|
||||
span(.class("monogram monogram--xl")) {
|
||||
@@ -116,20 +122,24 @@ extension ShopPage: Page {
|
||||
div(.class("shop__actions")) {
|
||||
a(
|
||||
.class("button button--primary"),
|
||||
.href(signIn)
|
||||
.href(.Route.gated(.Route.message(shop: shop.slug), from: shop.path, viewer: viewer))
|
||||
) {
|
||||
Icon(.message, size: 16)
|
||||
"Message shop"
|
||||
}
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(signIn)
|
||||
) {
|
||||
"Follow shop"
|
||||
}
|
||||
span(.class("type-small shop__gate")) {
|
||||
Icon(.key, size: 13)
|
||||
"Both need a passkey sign-in"
|
||||
if viewer == nil {
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(.Route.signIn(returningTo: shop.path))
|
||||
) {
|
||||
"Follow shop"
|
||||
}
|
||||
span(.class("type-small shop__gate")) {
|
||||
Icon(.key, size: 13)
|
||||
"Both need a passkey sign-in"
|
||||
}
|
||||
} else {
|
||||
Unavailable("Follow shop", modifier: "button--outline")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,11 +296,6 @@ private extension ShopPage {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The sign-in screen, returning to this shop.
|
||||
var signIn: String {
|
||||
.Route.signIn(returningTo: shop.path)
|
||||
}
|
||||
|
||||
var stats: [(value: String, label: String)] {
|
||||
[
|
||||
("\(counts.values.reduce(0, +))", "listings"),
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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.
|
||||
///
|
||||
/// The prototype stubs the passkey ceremony — 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)
|
||||
) {
|
||||
input(
|
||||
.type(.hidden),
|
||||
.name("return"),
|
||||
.value(returnPath)
|
||||
)
|
||||
button(
|
||||
.class("button button--primary button--xl button--block"),
|
||||
.type(.submit)
|
||||
) {
|
||||
Icon(.key, size: 18)
|
||||
"Continue with passkey"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
div(.class("gate__saved")) {
|
||||
Icon(.key, size: 16)
|
||||
span {
|
||||
strong {
|
||||
AccountRepository.demoEmail
|
||||
}
|
||||
span(.class("gate__saved-note")) {
|
||||
" · saved passkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("field__hint")) {
|
||||
"Unknown email? We create your account and passkey in the same step — nothing else to fill in."
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("notice notice--warn")) {
|
||||
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo account; an email signs in as that account, or creates it."
|
||||
}
|
||||
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]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.signIn]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Sign in or join — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -64,6 +64,7 @@ extension StaticFile {
|
||||
.search,
|
||||
.shared,
|
||||
.shop,
|
||||
.signIn,
|
||||
.site,
|
||||
.sitemap,
|
||||
]
|
||||
@@ -149,6 +150,9 @@ extension StaticFile {
|
||||
/// The `css/shop.css` stylesheet for the shop page.
|
||||
static let shop = Self("shop", as: .css)
|
||||
|
||||
/// The `css/sign-in.css` stylesheet for the sign-in page.
|
||||
static let signIn = Self("sign-in", as: .css)
|
||||
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
static let site = Self("site", as: .webmanifest)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Persistence
|
||||
|
||||
/// The signed-in person a page is rendered for.
|
||||
struct Viewer: Sendable {
|
||||
|
||||
@@ -35,8 +37,26 @@ struct Viewer: Sendable {
|
||||
|
||||
extension Viewer {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the viewer an account signs in as.
|
||||
/// - Parameter user: the signed-in account.
|
||||
init(
|
||||
_ user: User
|
||||
) {
|
||||
self.init(displayName: user.displayName)
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The first word of the display name, shown beside the avatar.
|
||||
var firstName: String {
|
||||
displayName
|
||||
.split(separator: " ")
|
||||
.first
|
||||
.map(String.init) ?? displayName
|
||||
}
|
||||
|
||||
/// The uppercased initials of the first two words of the display name.
|
||||
var initials: String {
|
||||
displayName
|
||||
|
||||
@@ -21,21 +21,27 @@ public struct CatalogueController<Context: RequestContext> {
|
||||
/// The repository the shops and listings are read through.
|
||||
private let repository: CatalogueRepository
|
||||
|
||||
/// The store the signed-in viewer is read from, or `nil` to render every page signed out.
|
||||
private let sessions: SessionStore?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a catalogue controller.
|
||||
/// - Parameters:
|
||||
/// - repository: the repository the shops and listings are read through.
|
||||
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
|
||||
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
repository: CatalogueRepository,
|
||||
sessions: SessionStore? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.repository = repository
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
@@ -93,10 +99,13 @@ private extension CatalogueController {
|
||||
.filter { $0.slug != listing.slug }
|
||||
.prefix(Self.relatedCount)
|
||||
|
||||
return .init { [analytics, assetVersion] in
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
ListingPage(
|
||||
listing: listing,
|
||||
related: Array(related),
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
@@ -113,11 +122,14 @@ private extension CatalogueController {
|
||||
let matches = try await repository.listings(matching: parameters.filter)
|
||||
let publishers = try await repository.publishers()
|
||||
|
||||
return .init { [analytics, assetVersion] in
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
SearchPage(
|
||||
parameters: parameters,
|
||||
matches: matches,
|
||||
publishers: publishers,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
@@ -138,12 +150,15 @@ private extension CatalogueController {
|
||||
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
|
||||
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
|
||||
|
||||
return .init { [analytics, assetVersion] in
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
ShopPage(
|
||||
shop: shop,
|
||||
inventory: inventory,
|
||||
matches: matches,
|
||||
parameters: parameters,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
@@ -18,6 +19,12 @@ public struct RootController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// What a page rendered per request is built from, the same as the cached ones.
|
||||
private let page: @Sendable (Locale, Viewer?) -> IndexPage
|
||||
|
||||
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
|
||||
private let sessions: SessionStore?
|
||||
|
||||
/// Negotiates the language the bare route answers in.
|
||||
private let negotiate: Negotiate
|
||||
|
||||
@@ -32,26 +39,35 @@ public struct RootController<Context: RequestContext> {
|
||||
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
/// - listings: the listings the landing page shows in its rows; none by default.
|
||||
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to serve everyone the cached page.
|
||||
public init(
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
listings: HomeListings = .init()
|
||||
listings: HomeListings = .init(),
|
||||
sessions: SessionStore? = nil
|
||||
) {
|
||||
let page: @Sendable (Locale, Viewer?) -> IndexPage = {
|
||||
.init(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings,
|
||||
viewer: $1
|
||||
)
|
||||
}
|
||||
|
||||
self.negotiate = .init(bundle: .module)
|
||||
self.page = page
|
||||
self.sessions = sessions
|
||||
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
|
||||
// and carry it too.
|
||||
self.responses = .init(
|
||||
bundle: .module,
|
||||
variesOnAcceptLanguage: true
|
||||
) {
|
||||
IndexPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings
|
||||
)
|
||||
page($0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,10 +119,11 @@ private extension RootController {
|
||||
func index(
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
responses.response(
|
||||
for: negotiate(for: request),
|
||||
request: request
|
||||
) async -> Response {
|
||||
await respond(
|
||||
in: negotiate(for: request),
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,13 +135,37 @@ private extension RootController {
|
||||
/// - Returns: the handler answering requests for that edition of the page.
|
||||
func index(
|
||||
in language: Language
|
||||
) -> @Sendable (Request, Context) -> Response {
|
||||
{ request, _ in
|
||||
responses.response(
|
||||
for: language.identifier,
|
||||
) -> @Sendable (Request, Context) async -> Response {
|
||||
{ request, context in
|
||||
await respond(
|
||||
in: language.identifier,
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers with the landing page in the given language: the cached page signed out, and one rendered around the viewer signed in.
|
||||
/// - Parameters:
|
||||
/// - language: the identifier of the language to answer in.
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
func respond(
|
||||
in language: String,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async -> Response {
|
||||
guard let viewer = await sessions?.viewer(for: request) else {
|
||||
return responses.response(
|
||||
for: language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
|
||||
page(.init(identifier: language), viewer)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Utility
|
||||
|
||||
/// Serves the gate: the sign-in screen, the sign-in itself, and the sign-out.
|
||||
///
|
||||
/// The passkey ceremony is **stubbed**: the prototype trusts whatever email it is given, and signs in the demo account when given none.
|
||||
/// Everything around the stub is real — the session, its cookie, and the return to whatever the visitor was about to do.
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct SessionController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts are found and created through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
/// The analytics tracker the page embeds, or `nil` to omit it.
|
||||
private let analytics: Analytics?
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository the visitor's intent is described from.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// Whether the session cookie is limited to HTTPS.
|
||||
private let isSecure: Bool
|
||||
|
||||
/// The store sessions are started and ended in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a session controller.
|
||||
/// - Parameters:
|
||||
/// - sessions: the store sessions are started and ended in.
|
||||
/// - accounts: the repository accounts are found and created through.
|
||||
/// - catalogue: the repository the visitor's intent is described from.
|
||||
/// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
sessions: SessionStore,
|
||||
accounts: AccountRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
isSecure: Bool = false,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.isSecure = isSecure
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension SessionController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Session.signIn,
|
||||
use: show
|
||||
)
|
||||
routes.post(
|
||||
.Session.signIn,
|
||||
use: signIn
|
||||
)
|
||||
routes.post(
|
||||
.Session.signOut,
|
||||
use: signOut
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SessionController {
|
||||
|
||||
// MARK: Structs
|
||||
|
||||
/// The fields the sign-in form posts.
|
||||
struct SignInForm: Decodable {
|
||||
/// The email typed in, or `nil` when the saved passkey is used.
|
||||
let email: String?
|
||||
/// The path to return to.
|
||||
let `return`: String?
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// What the visitor was about to do, read off the path they return to, or `nil` when it names no listing or shop.
|
||||
func intent(
|
||||
returningTo path: String
|
||||
) async throws -> String? {
|
||||
let segments = path
|
||||
.prefix { $0 != "?" }
|
||||
.split(separator: "/")
|
||||
.map(String.init)
|
||||
|
||||
guard segments.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch segments[0] {
|
||||
case "listing":
|
||||
return try await catalogue
|
||||
.listing(slug: segments[1])
|
||||
.map { "To buy, offer on or ask about \($0.title)" }
|
||||
case "shop":
|
||||
return try await catalogue
|
||||
.shop(slug: segments[1])
|
||||
.map { "To message or follow \($0.name)" }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the sign-in screen, never stored: it reflects the visitor's intent and their last attempt.
|
||||
func page(
|
||||
returnPath: String,
|
||||
error: String? = nil,
|
||||
status: HTTPResponse.Status = .ok,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let intent = try await intent(returningTo: returnPath)
|
||||
|
||||
return HTMLResponse(
|
||||
status: status,
|
||||
additionalHeaders: [.cacheControl: "no-store"]
|
||||
) { [analytics, assetVersion] in
|
||||
SignInPage(
|
||||
returnPath: returnPath,
|
||||
intent: intent,
|
||||
error: error,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
|
||||
@Sendable
|
||||
func show(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let returnPath = String.Route.safeReturn(request.uri.queryParameters.get("return"))
|
||||
|
||||
guard await sessions.viewer(for: request) == nil else {
|
||||
return .redirect(to: returnPath, type: .found)
|
||||
}
|
||||
|
||||
return try await page(
|
||||
returnPath: returnPath,
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles a sign-in: starts a session for the account — the demo one when no email is given — and returns to the visitor's intent.
|
||||
@Sendable
|
||||
func signIn(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let form = try await URLEncodedFormDecoder().decode(SignInForm.self, from: request, context: context)
|
||||
let returnPath = String.Route.safeReturn(form.return)
|
||||
let typed = form.email?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
guard let email = typed.isEmpty ? AccountRepository.demoEmail : NormalizeEmail()(typed) else {
|
||||
return try await page(
|
||||
returnPath: returnPath,
|
||||
error: "That doesn't look like an email address.",
|
||||
status: .unprocessableContent,
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
let token = await sessions.start(for: try await accounts.account(email: email))
|
||||
var response = Response.redirect(to: returnPath, type: .normal)
|
||||
|
||||
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// Handles a sign-out: ends the session, expires its cookie, and returns home.
|
||||
@Sendable
|
||||
func signOut(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
if let token = SessionStore.token(in: request) {
|
||||
await sessions.end(token)
|
||||
}
|
||||
|
||||
var response = Response.redirect(to: .Route.home, type: .normal)
|
||||
|
||||
response.setCookie(SessionStore.cookie("", isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``SessionController`` route paths.
|
||||
enum Session {
|
||||
/// The path of the sign-in screen, and of the sign-in posted from it.
|
||||
static let signIn: RouterPath = .init(.Route.signIn)
|
||||
/// The path a sign-out is posted to.
|
||||
static let signOut: RouterPath = .init(.Route.signOut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Persistence
|
||||
|
||||
/// The signed-in sessions, kept in memory: a token held in a cookie, naming an account.
|
||||
///
|
||||
/// Prototype scaffolding. Sessions end with the process — as the in-memory database does — and move to storage shared between instances
|
||||
/// together with real passkeys.
|
||||
public actor SessionStore {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The accounts signed in, by session token.
|
||||
private var users: [String: User] = [:]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an empty session store.
|
||||
public init() {}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Ends the session with the given token, if there is one.
|
||||
public func end(
|
||||
_ token: String
|
||||
) {
|
||||
users[token] = nil
|
||||
}
|
||||
|
||||
/// Starts a session for an account.
|
||||
/// - Parameter user: the account signing in.
|
||||
/// - Returns: the session's token, unguessable and unique.
|
||||
public func start(
|
||||
for user: User
|
||||
) -> String {
|
||||
let token = "\(UUID().uuidString)\(UUID().uuidString)"
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.lowercased()
|
||||
|
||||
users[token] = user
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/// The account the session with the given token belongs to, or `nil` when there is no such session.
|
||||
public func user(
|
||||
for token: String
|
||||
) -> User? {
|
||||
users[token]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension SessionStore {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The name of the cookie holding the session token.
|
||||
static let cookieName = "ccn_session"
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The cookie handing a session token to the browser: hidden from scripts, and sent along with same-site requests and top-level navigation alone.
|
||||
/// - Parameters:
|
||||
/// - token: the session's token; an empty one expires the cookie.
|
||||
/// - isSecure: whether the cookie is limited to HTTPS.
|
||||
nonisolated static func cookie(
|
||||
_ token: String,
|
||||
isSecure: Bool
|
||||
) -> Cookie {
|
||||
.init(
|
||||
name: cookieName,
|
||||
value: token,
|
||||
maxAge: token.isEmpty ? 0 : nil,
|
||||
path: "/",
|
||||
secure: isSecure,
|
||||
httpOnly: true,
|
||||
sameSite: .lax
|
||||
)
|
||||
}
|
||||
|
||||
/// The session token the request carries, or `nil` when it carries none.
|
||||
nonisolated static func token(
|
||||
in request: Request
|
||||
) -> String? {
|
||||
request.cookies[cookieName]?.value
|
||||
}
|
||||
|
||||
/// The signed-in viewer the request comes from, or `nil` when it carries no live session.
|
||||
func viewer(
|
||||
for request: Request
|
||||
) -> Viewer? {
|
||||
Self.token(in: request)
|
||||
.flatMap(user)
|
||||
.map(Viewer.init)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user