feat: Adds reset password views and view routes.

This commit is contained in:
2025-01-27 09:01:23 -05:00
parent 8a79ab0b02
commit f3ffdbf41b
10 changed files with 155 additions and 9 deletions

View File

@@ -17,6 +17,7 @@ extension SiteRoute.View {
var middleware: [any Middleware]? {
switch self {
case .employee,
.resetPassword,
.purchaseOrder,
.user,
.vendor,

View File

@@ -3,11 +3,13 @@ import Foundation
@preconcurrency import URLRouting
public extension SiteRoute {
// swiftlint:disable type_body_length
enum View: Sendable, Equatable {
case employee(SiteRoute.View.EmployeeRoute)
case login(SiteRoute.View.LoginRoute)
case purchaseOrder(SiteRoute.View.PurchaseOrderRoute)
case resetPassword(SiteRoute.View.ResetPasswordRoute)
case user(SiteRoute.View.UserRoute)
case vendor(SiteRoute.View.VendorRoute)
case vendorBranch(SiteRoute.View.VendorBranchRoute)
@@ -16,6 +18,7 @@ public extension SiteRoute {
Route(.case(Self.employee)) { SiteRoute.View.EmployeeRoute.router }
Route(.case(Self.login)) { SiteRoute.View.LoginRoute.router }
Route(.case(Self.purchaseOrder)) { SiteRoute.View.PurchaseOrderRoute.router }
Route(.case(Self.resetPassword)) { SiteRoute.View.ResetPasswordRoute.router }
Route(.case(Self.user)) { SiteRoute.View.UserRoute.router }
Route(.case(Self.vendor)) { SiteRoute.View.VendorRoute.router }
Route(.case(Self.vendorBranch)) { SiteRoute.View.VendorBranchRoute.router }
@@ -238,6 +241,31 @@ public extension SiteRoute {
}
}
public enum ResetPasswordRoute: Sendable, Equatable {
case index(id: User.ID)
case submit(id: User.ID, request: User.ResetPassword)
static let rootPath = "reset-password"
public static let router = OneOf {
Route(.case(Self.index(id:))) {
Path { rootPath; User.ID.parser() }
Method.get
}
Route(.case(Self.submit(id:request:))) {
Path { rootPath; User.ID.parser() }
Method.patch
Body {
FormData {
Field("password", .string)
Field("confirmPassword", .string)
}
.map(.memberwise(User.ResetPassword.init))
}
}
}
}
public enum SelectContext: String, Codable, Equatable, Sendable, CaseIterable {
case purchaseOrderForm
case purchaseOrderSearch
@@ -375,4 +403,5 @@ public extension SiteRoute {
}
}
}
// swiftlint:enable type_body_length
}

View File

@@ -40,6 +40,9 @@ public extension SiteRoute.View {
case let .purchaseOrder(route):
return try await route.view(isHtmxRequest: isHtmxRequest)
case let .resetPassword(route):
return try await route.view(isHtmxRequest: isHtmxRequest)
case let .user(route):
return try await route.view(isHtmxRequest: isHtmxRequest)
@@ -180,6 +183,22 @@ extension SiteRoute.View.PurchaseOrderRoute.Search {
}
}
extension SiteRoute.View.ResetPasswordRoute {
@Sendable
func view(isHtmxRequest: Bool) async throws -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case let .index(id: id):
return UserForm(context: .resetPassword(id: id))
case let .submit(id: id, request: request):
try await database.users.resetPassword(id, request)
let user = try await database.users.get(id)
return UserDetail(user: user)
}
}
}
extension SiteRoute.View.UserRoute {
private func mainPage<C: HTML>(_ html: C) async throws -> AnySendableHTML where C: Sendable {

View File

@@ -49,7 +49,10 @@ struct UserDetail: HTML, Sendable {
)
// TODO: trigger the reset password route.
button(
.class("btn-primary")
.class("btn-primary"),
.hx.target(.id(.float)),
.hx.get(route: .resetPassword(.index(id: user.id))),
.hx.trigger(.event(.click))
) { "Reset Password" }
}
}

View File

@@ -7,7 +7,7 @@ struct UserForm: HTML, Sendable {
let context: Context
var content: some HTML {
if context == .create {
if context.isFloat {
Float(shouldDisplay: true) {
makeForm()
}
@@ -26,7 +26,7 @@ struct UserForm: HTML, Sendable {
.hx.swap(context == .create ? .afterBegin.transition(true).swap("0.5s") : .outerHTML),
.hx.on(
.afterRequest,
.ifSuccessful(.resetForm, .toggleContent(.float))
context.toggleContent ? .ifSuccessful(.resetForm, .toggleContent(.float)) : .ifSuccessful(.resetForm)
)
) {
if case let .login(next) = context, let next {
@@ -70,6 +70,21 @@ struct UserForm: HTML, Sendable {
return true
}
var isFloat: Bool {
switch self {
case .create,
.resetPassword:
return true
case .login:
return false
}
}
var toggleContent: Bool {
guard case .resetPassword = self else { return true }
return false
}
var showUsername: Bool {
switch self {
case .create: return true
@@ -120,8 +135,7 @@ struct UserForm: HTML, Sendable {
case .login:
return .body
case .resetPassword:
// FIX: doesn't return anything
return .this
return .id(.float)
}
}
@@ -131,9 +145,8 @@ struct UserForm: HTML, Sendable {
return .user(.index)
case .login:
return .login(.index())
case .resetPassword:
// FIX: Route.
return .user(.index)
case let .resetPassword(id: id):
return .resetPassword(.index(id: id))
}
}
}

View File

@@ -65,6 +65,27 @@ struct UserApiRouteTests {
#expect(route == .user(.index))
}
@Test
func resetPassword() throws {
let id = UUID(0)
let json = """
{
\"password\": \"super-secret\",
\"confirmPassword\": \"super-secret\"
}
"""
var request = URLRequestData(
method: "PATCH",
path: "/api/v1/users/\(id)/reset-password",
body: .init(json.utf8)
)
let route = try router.parse(&request)
#expect(route == .user(.resetPassword(
id: id,
request: .init(password: "super-secret", confirmPassword: "super-secret")
)))
}
@Test
func update() throws {
let id = UUID(0)

View File

@@ -101,6 +101,25 @@ struct ViewControllerTests {
}
}
@Test
func resetPasswordViews() async throws {
try await withSnapshotTesting(record: record) {
try await withDependencies {
$0.dateFormatter = .mock
$0.database.users = .mock
$0.viewController = .liveValue
} operation: {
@Dependency(\.viewController) var viewController
var htmlString = try await viewController.render(.resetPassword(.index(id: UUID(0))))
assertSnapshot(of: htmlString, as: .html)
htmlString = try await viewController.render(.resetPassword(.submit(id: UUID(0), request: .mock)))
assertSnapshot(of: htmlString, as: .html)
}
}
}
@Test
func userViews() async throws {
try await withSnapshotTesting(record: record) {
@@ -408,3 +427,9 @@ extension PurchaseOrder.Create {
)
}
}
extension User.ResetPassword {
static var mock: Self {
.init(password: "super-secret", confirmPassword: "super-secret")
}
}

View File

@@ -0,0 +1,16 @@
<div id="float" class="float" style="display: block;">
<div class="btn-row">
<button class="btn-close" onclick="toggleContent('float');">x</button>
</div>
<form id="user-form" class="user-form" hx-patch="/reset-password/00000000-0000-0000-0000-000000000000" hx-push-url="false" hx-target="#float" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful) this.reset();">
<div class="row">
<input type="password" id="password" name="password" placeholder="Password" required>
</div>
<div class="row">
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="Confirm Password" required>
</div>
<div class="row">
<button type="submit" class="btn-primary">Reset</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,19 @@
<div id="float" class="float" style="display: block;">
<div class="btn-row">
<button class="btn-close" onclick="toggleContent('float'); window.location.href='/users';">x</button>
</div>
<form hx-post="/users/00000000-0000-0000-0000-000000000000" hx-swap="outerHTML" hx-target="#user-00000000-0000-0000-0000-000000000000" hx-on::after-request="toggleContent('float');">
<div class="row">
<label for="username" class="col-2"><span class="label">Username:</span></label>
<input class="col-4" type="text" id="username" name="username" value="test" required>
Email:<label for="email" class="col-2"><span class="label"></span></label>
<input class="col-4" type="email" id="email" name="email" value="test@example.com" required>
</div>
<div class="row"><span class="label col-2">Created:</span><span class="date col-4">01/31/2025</span><span class="label col-2">Updated:</span><span class="date col-4">01/31/2025</span></div>
<div class="btn-row user-buttons">
<button type="submit" class="btn-secondary">Update</button>
<button class="danger" hx-delete="/api/v1/users/00000000-0000-0000-0000-000000000000" hx-trigger="click" hx-swap="outerHTML" hx-target="#user-00000000-0000-0000-0000-000000000000" hx-confirm="Are you sure you want to delete this user?" hx-on::after-request="toggleContent('float'); window.location.href='/users';">Delete</button>
<button class="btn-primary" hx-target="#float" hx-get="/reset-password/00000000-0000-0000-0000-000000000000" hx-trigger="click">Reset Password</button>
</div>
</form>
</div>

View File

@@ -13,7 +13,7 @@
<div class="btn-row user-buttons">
<button type="submit" class="btn-secondary">Update</button>
<button class="danger" hx-delete="/api/v1/users/00000000-0000-0000-0000-000000000000" hx-trigger="click" hx-swap="outerHTML" hx-target="#user-00000000-0000-0000-0000-000000000000" hx-confirm="Are you sure you want to delete this user?" hx-on::after-request="toggleContent('float'); window.location.href='/users';">Delete</button>
<button class="btn-primary">Reset Password</button>
<button class="btn-primary" hx-target="#float" hx-get="/reset-password/00000000-0000-0000-0000-000000000000" hx-trigger="click">Reset Password</button>
</div>
</form>
</div>