WIP: Working signup and login forms, along with initial view auth middleware.

This commit is contained in:
2026-01-03 11:30:42 -05:00
parent 6c6045b4a6
commit 1d155546ae
14 changed files with 316 additions and 1152 deletions

View File

@@ -13,10 +13,10 @@ extension ViewController {
route: route, route: route,
isHtmxRequest: request.isHtmxRequest, isHtmxRequest: request.isHtmxRequest,
logger: request.logger, logger: request.logger,
// authenticate: { request.session.authenticate($0) }, authenticateUser: { request.session.authenticate($0) },
// currentUser: { currentUser: {
// try request.auth.require(User.self) try request.auth.require(User.self)
// } }
) )
) )
return AnyHTMLResponse(value: html) return AnyHTMLResponse(value: html)

View File

@@ -0,0 +1,24 @@
import DatabaseClient
import Fluent
import ManualDCore
import Vapor
private let viewRouteMiddleware: [any Middleware] = [
UserPasswordAuthenticator(),
UserSessionAuthenticator(),
User.redirectMiddleware(path: "/login"),
]
extension SiteRoute.View {
var middleware: [any Middleware]? {
switch self {
case .project,
.frictionRate,
.effectiveLength,
.room:
return viewRouteMiddleware
case .login, .signup:
return nil
}
}
}

View File

@@ -100,15 +100,14 @@ private func addCommands(to app: Application) {
extension SiteRoute { extension SiteRoute {
fileprivate func middleware() -> [any Middleware]? { fileprivate func middleware() -> [any Middleware]? {
return nil switch self {
// switch self { case .api:
// case .api(let route): return nil
// return route.middleware case .health:
// // case .health: return nil
// // return nil case .view(let route):
// case .view(let route): return route.middleware
// return route.middleware }
// }
} }
} }

View File

@@ -18,6 +18,7 @@ public struct DatabaseClient: Sendable {
public var equipment: Equipment public var equipment: Equipment
public var componentLoss: ComponentLoss public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient public var effectiveLength: EffectiveLengthClient
public var users: Users
} }
extension DatabaseClient: TestDependencyKey { extension DatabaseClient: TestDependencyKey {
@@ -27,7 +28,8 @@ extension DatabaseClient: TestDependencyKey {
rooms: .testValue, rooms: .testValue,
equipment: .testValue, equipment: .testValue,
componentLoss: .testValue, componentLoss: .testValue,
effectiveLength: .testValue effectiveLength: .testValue,
users: .testValue
) )
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
@@ -37,7 +39,8 @@ extension DatabaseClient: TestDependencyKey {
rooms: .live(database: database), rooms: .live(database: database),
equipment: .live(database: database), equipment: .live(database: database),
componentLoss: .live(database: database), componentLoss: .live(database: database),
effectiveLength: .live(database: database) effectiveLength: .live(database: database),
users: .live(database: database)
) )
} }
} }

View File

@@ -10,7 +10,7 @@ extension DatabaseClient {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void public var delete: @Sendable (Project.ID) async throws -> Void
public var get: @Sendable (Project.ID) async throws -> Project? public var get: @Sendable (Project.ID) async throws -> Project?
public var fetch: @Sendable (User.ID) async throws -> [Project] public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
} }
} }
@@ -33,11 +33,12 @@ extension DatabaseClient.Projects: TestDependencyKey {
get: { id in get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() } try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
}, },
fetch: { userID in fetch: { userID, request in
try await ProjectModel.query(on: database) try await ProjectModel.query(on: database)
.sort(\.$createdAt, .descending)
.with(\.$user) .with(\.$user)
.filter(\.$user.$id == userID) .filter(\.$user.$id == userID)
.all() .paginate(request)
.map { try $0.toDTO() } .map { try $0.toDTO() }
} }
) )

View File

@@ -83,8 +83,8 @@ extension User {
.field("username", .string, .required) .field("username", .string, .required)
.field("email", .string, .required) .field("email", .string, .required)
.field("password_hash", .string, .required) .field("password_hash", .string, .required)
.field("created_at", .datetime) .field("createdAt", .datetime)
.field("updated_at", .datetime) .field("updatedAt", .datetime)
.unique(on: "email", "username") .unique(on: "email", "username")
.create() .create()
} }

View File

@@ -8,16 +8,20 @@ extension SiteRoute {
/// The routes return html. /// The routes return html.
public enum View: Equatable, Sendable { public enum View: Equatable, Sendable {
case login(LoginRoute) case login(LoginRoute)
case signup(SignupRoute)
case project(ProjectRoute) case project(ProjectRoute)
case room(RoomRoute) case room(RoomRoute)
case frictionRate(FrictionRateRoute) case frictionRate(FrictionRateRoute)
case effectiveLength(EffectiveLengthRoute) case effectiveLength(EffectiveLengthRoute)
case user(UserRoute) // case user(UserRoute)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.login)) { Route(.case(Self.login)) {
SiteRoute.View.LoginRoute.router SiteRoute.View.LoginRoute.router
} }
Route(.case(Self.signup)) {
SiteRoute.View.SignupRoute.router
}
Route(.case(Self.project)) { Route(.case(Self.project)) {
SiteRoute.View.ProjectRoute.router SiteRoute.View.ProjectRoute.router
} }
@@ -30,9 +34,9 @@ extension SiteRoute {
Route(.case(Self.effectiveLength)) { Route(.case(Self.effectiveLength)) {
SiteRoute.View.EffectiveLengthRoute.router SiteRoute.View.EffectiveLengthRoute.router
} }
Route(.case(Self.user)) { // Route(.case(Self.user)) {
SiteRoute.View.UserRoute.router // SiteRoute.View.UserRoute.router
} // }
} }
} }
} }
@@ -42,6 +46,7 @@ extension SiteRoute.View {
case create(Project.Create) case create(Project.Create)
case form(dismiss: Bool = false) case form(dismiss: Bool = false)
case index case index
case page(page: Int = 1, limit: Int = 25)
static let rootPath = "projects" static let rootPath = "projects"
@@ -74,6 +79,17 @@ extension SiteRoute.View {
Path { rootPath } Path { rootPath }
Method.get Method.get
} }
Route(.case(Self.page(page:limit:))) {
Path {
rootPath
"page"
}
Method.get
Query {
Field("page", default: 1) { Int.parser() }
Field("limit", default: 25) { Int.parser() }
}
}
} }
} }
} }
@@ -182,17 +198,17 @@ extension SiteRoute.View.EffectiveLengthRoute {
} }
} }
extension SiteRoute.View { // extension SiteRoute.View {
public enum UserRoute: Equatable, Sendable { // public enum UserRoute: Equatable, Sendable {
case signup(Signup) // case signup(Signup)
//
public static let router = OneOf { // public static let router = OneOf {
Route(.case(Self.signup)) { // Route(.case(Self.signup)) {
SiteRoute.View.UserRoute.Signup.router // SiteRoute.View.UserRoute.Signup.router
} // }
} // }
} // }
} // }
extension SiteRoute.View { extension SiteRoute.View {
@@ -222,9 +238,9 @@ extension SiteRoute.View {
} }
} }
extension SiteRoute.View.UserRoute { extension SiteRoute.View {
public enum Signup: Equatable, Sendable { public enum SignupRoute: Equatable, Sendable {
case index case index
case submit(User.Create) case submit(User.Create)

View File

@@ -0,0 +1,26 @@
import Elementary
public struct Indicator: HTML, Sendable {
let size: Size
public init(size: Size = .lg) {
self.size = size
}
public var body: some HTML<HTMLTag.span> {
span(.class("loading loading-spinner \(size.class) htmx-indicator")) {}
}
public enum Size: String, Equatable, Sendable {
case xs
case sm
case md
case lg
case xl
var `class`: String {
"loading-\(rawValue)"
}
}
}

View File

@@ -15,6 +15,10 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient @DependencyClient
public struct ViewController: Sendable { public struct ViewController: Sendable {
public typealias AuthenticateHandler = @Sendable (User) -> Void
public typealias CurrentUserHandler = @Sendable () throws -> User
public var view: @Sendable (Request) async throws -> AnySendableHTML public var view: @Sendable (Request) async throws -> AnySendableHTML
} }
@@ -25,15 +29,25 @@ extension ViewController {
public let route: SiteRoute.View public let route: SiteRoute.View
public let isHtmxRequest: Bool public let isHtmxRequest: Bool
public let logger: Logger public let logger: Logger
public let authenticateUser: AuthenticateHandler
public let currentUser: CurrentUserHandler
public init( public init(
route: SiteRoute.View, route: SiteRoute.View,
isHtmxRequest: Bool, isHtmxRequest: Bool,
logger: Logger logger: Logger,
authenticateUser: @escaping AuthenticateHandler,
currentUser: @escaping CurrentUserHandler
) { ) {
self.route = route self.route = route
self.isHtmxRequest = isHtmxRequest self.isHtmxRequest = isHtmxRequest
self.logger = logger self.logger = logger
self.authenticateUser = authenticateUser
self.currentUser = currentUser
}
func authenticate(_ user: User) {
self.authenticateUser(user)
} }
} }
} }

View File

@@ -1,11 +1,32 @@
import DatabaseClient
import Dependencies
import Elementary import Elementary
import Foundation
import ManualDCore import ManualDCore
extension ViewController.Request { extension ViewController.Request {
func render() async throws -> AnySendableHTML { func render() async throws -> AnySendableHTML {
@Dependency(\.database) var database
switch route { switch route {
case .login(let route): case .login(let route):
switch route {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm()
}
case .submit(let login):
let token = try await database.users.login(login)
let user = try await database.users.get(token.userID)!
authenticate(user)
let projects = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
ProjectsTable(userID: user.id, projects: projects)
}
}
case .signup(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .project(let route): case .project(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
@@ -15,11 +36,11 @@ extension ViewController.Request {
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .effectiveLength(let route): case .effectiveLength(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .user(let route): // case .user(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) // return try await route.renderView(isHtmxRequest: isHtmxRequest)
default: default:
// FIX: FIX // FIX: FIX
return _render(isHtmxRequest: false) { return try await _render(isHtmxRequest: false) {
div { "Fix me!" } div { "Fix me!" }
} }
} }
@@ -27,11 +48,29 @@ extension ViewController.Request {
} }
extension SiteRoute.View.ProjectRoute { extension SiteRoute.View.ProjectRoute {
private var shouldShowSidebar: Bool {
switch self {
case .index, .page: return false
default: return true
}
}
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
_render(isHtmxRequest: isHtmxRequest) { @Dependency(\.database.projects) var projects
return try await _render(
isHtmxRequest: isHtmxRequest,
showSidebar: shouldShowSidebar
) {
switch self { switch self {
case .index: case .index:
ProjectView(project: .mock) // ProjectView(project: .mock)
let page = try await projects.fetch(UUID(0), .init(page: 1, per: 25))
ProjectsTable(userID: UUID(0), projects: page)
case .page(let page, let limit):
let page = try await projects.fetch(UUID(0), .init(page: page, per: limit))
ProjectsTable.Rows(projects: page)
case .form(let dismiss): case .form(let dismiss):
ProjectForm(dismiss: dismiss) ProjectForm(dismiss: dismiss)
case .create: case .create:
@@ -47,7 +86,7 @@ extension SiteRoute.View.RoomRoute {
case .form(let dismiss): case .form(let dismiss):
return RoomForm(dismiss: dismiss) return RoomForm(dismiss: dismiss)
case .index: case .index:
return _render(isHtmxRequest: isHtmxRequest, active: .rooms) { return try await _render(isHtmxRequest: isHtmxRequest, active: .rooms) {
RoomsView(rooms: Room.mocks) RoomsView(rooms: Room.mocks)
} }
} }
@@ -58,7 +97,7 @@ extension SiteRoute.View.FrictionRateRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { switch self {
case .index: case .index:
return _render(isHtmxRequest: isHtmxRequest, active: .frictionRate) { return try await _render(isHtmxRequest: isHtmxRequest, active: .frictionRate) {
FrictionRateView() FrictionRateView()
} }
case .form(let type, let dismiss): case .form(let type, let dismiss):
@@ -89,7 +128,7 @@ extension SiteRoute.View.EffectiveLengthRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { switch self {
case .index: case .index:
return _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) { return try await _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) {
EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks) EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks)
} }
case .form(let dismiss): case .form(let dismiss):
@@ -106,51 +145,66 @@ extension SiteRoute.View.EffectiveLengthRoute {
} }
} }
extension SiteRoute.View.UserRoute { extension SiteRoute.View.SignupRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
@Dependency(\.database.users) var users
switch self { switch self {
// case .login(.index): case .index:
// return _render(isHtmxRequest: isHtmxRequest, showSidebar: false) { return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
// LoginForm()
// }
case .signup(.index):
return _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm(style: .signup) LoginForm(style: .signup)
} }
default: case .submit(let request):
return div { "Fix Me!" } _ = try await users.create(request)
// FIX: We should just login the new user at this point.
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm()
}
// default:
// return div { "Fix Me!" }
} }
} }
} }
extension SiteRoute.View.LoginRoute { // extension SiteRoute.View.LoginRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { // func renderView(on req: ViewController.Request) async throws -> AnySendableHTML {
_render(isHtmxRequest: isHtmxRequest, showSidebar: false) { //
switch self { // @Dependency(\.database) var database
case .index: //
LoginForm() // return try await _render(isHtmxRequest: req.isHtmxRequest, showSidebar: false) {
case .submit: // switch self {
// FIX: // case .index:
div { "Fix me!" } // LoginForm()
} // case .submit(let login):
} // // FIX:
} // // div { "Logged in Success! Fix me!" }
} // let token = try await database.users.login(login)
// let user = try await database.users.get(token.userID)!
// _ = req.authenticate(user)
// // req.authenticate(user)
// let page = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
// ProjectsTable(userID: user.id, projects: page)
// }
// }
// }
// }
private func _render<C: HTML>( private func _render<C: HTML>(
isHtmxRequest: Bool, isHtmxRequest: Bool,
active activeTab: Sidebar.ActiveTab = .projects, active activeTab: Sidebar.ActiveTab = .projects,
showSidebar: Bool = true, showSidebar: Bool = true,
@HTMLBuilder inner: () -> C @HTMLBuilder inner: () async throws -> C
) -> AnySendableHTML where C: Sendable { ) async throws -> AnySendableHTML where C: Sendable {
let inner = try await inner()
if isHtmxRequest { if isHtmxRequest {
return inner() return inner
} }
return MainPage( return MainPage(
active: activeTab, active: activeTab,
showSidebar: showSidebar showSidebar: showSidebar
) { ) {
inner() inner
} }
} }

View File

@@ -0,0 +1,80 @@
import Elementary
import ElementaryHTMX
import Fluent
import ManualDCore
import Styleguide
import Vapor
struct ProjectsTable: HTML, Sendable {
let userID: User.ID
let projects: Page<Project>
init(userID: User.ID, projects: Page<Project>) {
self.userID = userID
self.projects = projects
}
var body: some HTML {
div {
Row {
h1(.class("text-2xl font-bold")) { "Projects" }
div(
.class("tooltip tooltip-left"),
.data("tip", value: "Add project")
) {
button(
.class("btn btn-primary w-[40px] text-2xl")
) {
"+"
}
}
}
.attributes(.class("pb-6"))
div(.class("overflow-x-auto rounded-box border")) {
table(.class("table table-zebra")) {
thead {
tr {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
}
}
tbody {
Rows(projects: projects)
}
}
}
}
}
}
extension ProjectsTable {
struct Rows: HTML, Sendable {
let projects: Page<Project>
var body: some HTML {
for project in projects.items {
tr(.id("\(project.id)")) {
td { "\(project.createdAt)" }
td { "\(project.name)" }
td { "\(project.streetAddress)" }
}
}
// Have a row that when revealed fetches the next page,
// if there are more pages left.
if projects.metadata.pageCount > projects.metadata.page {
tr(
.hx.get(route: .project(.page(page: projects.metadata.page + 1, limit: 25))),
.hx.trigger(.event(.revealed)),
.hx.swap(.outerHTML),
.hx.target("this"),
.hx.indicator("next .htmx-indicator")
) {
Indicator(size: .lg)
}
}
}
}
}

View File

@@ -15,7 +15,9 @@ struct LoginForm: HTML, Sendable {
.id("loginForm"), .id("loginForm"),
.class("flex items-center justify-center") .class("flex items-center justify-center")
) { ) {
form { form(
.method(.post)
) {
fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) { fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) {
legend(.class("fieldset-legend")) { style.title } legend(.class("fieldset-legend")) { style.title }
@@ -24,6 +26,7 @@ struct LoginForm: HTML, Sendable {
SVG(.user) SVG(.user)
input( input(
.type(.text), .required, .placeholder("Username"), .type(.text), .required, .placeholder("Username"),
.name("username"), .id("username"),
.minlength("3"), .pattern(.username) .minlength("3"), .pattern(.username)
) )
} }
@@ -37,7 +40,8 @@ struct LoginForm: HTML, Sendable {
label(.class("input validator w-full")) { label(.class("input validator w-full")) {
SVG(.email) SVG(.email)
input( input(
.type(.email), .placeholder("Email"), .required .type(.email), .placeholder("Email"), .required,
.name("email"), .id("email"),
) )
} }
div(.class("validator-hint hidden")) { "Enter valid email address." } div(.class("validator-hint hidden")) { "Enter valid email address." }
@@ -46,7 +50,8 @@ struct LoginForm: HTML, Sendable {
SVG(.key) SVG(.key)
input( input(
.type(.password), .placeholder("Password"), .required, .type(.password), .placeholder("Password"), .required,
.pattern(.password), .minlength("8") .pattern(.password), .minlength("8"),
.name("password"), .id("password"),
) )
} }
@@ -55,7 +60,8 @@ struct LoginForm: HTML, Sendable {
SVG(.key) SVG(.key)
input( input(
.type(.password), .placeholder("Confirm Password"), .required, .type(.password), .placeholder("Confirm Password"), .required,
.pattern(.password), .minlength("8") .pattern(.password), .minlength("8"),
.name("confirmPassword"), .id("confirmPassword"),
) )
} }
} }
@@ -75,7 +81,7 @@ struct LoginForm: HTML, Sendable {
button(.class("btn btn-secondary mt-4")) { style.title } button(.class("btn btn-secondary mt-4")) { style.title }
a( a(
.class("btn btn-link mt-4"), .class("btn btn-link mt-4"),
.href(route: style == .signup ? .login(.index) : .user(.signup(.index))) .href(route: style == .signup ? .login(.index) : .signup(.index))
) { ) {
style == .login ? "Sign Up" : "Login" style == .login ? "Sign Up" : "Login"
} }

View File

@@ -7,7 +7,7 @@ run-css:
@./tailwindcss -i input.css -o output.css --watch @./tailwindcss -i input.css -o output.css --watch
run: run:
@swift run App @SWIFT_BACTRACE=enable=no swift run App
build-docker: build-docker:
@podman build -f docker/Dockerfile.dev -t {{docker_image}}:dev . @podman build -f docker/Dockerfile.dev -t {{docker_image}}:dev .

1089
output.css

File diff suppressed because it is too large Load Diff