Reset Password (#1)
Implements reset password routes, views, and tests. Reviewed-on: #1
This commit is contained in:
@@ -110,6 +110,9 @@ private extension SiteRoute.Api.UserRoute {
|
||||
return user
|
||||
// case let .login(user):
|
||||
// return try await users.login(user)
|
||||
case let .resetPassword(id: id, request: request):
|
||||
try await users.resetPassword(id, request)
|
||||
return nil
|
||||
case let .update(id: id, updates: updates):
|
||||
return try await users.update(id, updates)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ extension SiteRoute.View {
|
||||
var middleware: [any Middleware]? {
|
||||
switch self {
|
||||
case .employee,
|
||||
.resetPassword,
|
||||
.purchaseOrder,
|
||||
.user,
|
||||
.vendor,
|
||||
|
||||
@@ -15,6 +15,7 @@ public extension DatabaseClient {
|
||||
public var get: @Sendable (User.ID) async throws -> User?
|
||||
public var login: @Sendable (User.Login) async throws -> User.Token
|
||||
public var logout: @Sendable (User.Token.ID) async throws -> Void
|
||||
public var resetPassword: @Sendable (User.ID, User.ResetPassword) async throws -> Void
|
||||
public var token: @Sendable (User.ID) async throws -> User.Token
|
||||
public var update: @Sendable (User.ID, User.Update) async throws -> User
|
||||
}
|
||||
|
||||
@@ -57,6 +57,18 @@ public extension DatabaseClient.Users {
|
||||
guard let token = try await UserTokenModel.find(id, on: database)
|
||||
else { return }
|
||||
try await token.delete(on: database)
|
||||
} resetPassword: { id, request in
|
||||
database.logger.debug("Reset password: \(id)")
|
||||
|
||||
try request.validate()
|
||||
|
||||
guard let user = try await UserModel.find(id, on: database) else {
|
||||
throw Abort(.badRequest, reason: "User not found.")
|
||||
}
|
||||
|
||||
user.passwordHash = try User.hashPassword(request.password)
|
||||
try await user.save(on: database)
|
||||
|
||||
} token: { _ in
|
||||
guard let user = try await UserModel.query(on: database)
|
||||
.with(\.$token)
|
||||
@@ -134,11 +146,19 @@ extension User.Token {
|
||||
}
|
||||
}
|
||||
|
||||
extension User {
|
||||
|
||||
static func hashPassword(_ password: String) throws -> String {
|
||||
try Bcrypt.hash(password, cost: 12)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension User.Create {
|
||||
|
||||
func toModel() throws -> UserModel {
|
||||
try validate()
|
||||
return try .init(username: username, email: email, passwordHash: Bcrypt.hash(password, cost: 12))
|
||||
return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
@@ -166,6 +186,18 @@ extension User.Login {
|
||||
}
|
||||
}
|
||||
|
||||
extension User.ResetPassword {
|
||||
|
||||
func validate() throws {
|
||||
guard password.count > 8 else {
|
||||
throw ValidationError(message: "Password should be more than 8 characters long.")
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
throw ValidationError(message: "Passwords do not match.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user database model.
|
||||
///
|
||||
/// A user is someone who is able to login and generate PO's for employees. Generally a user should also
|
||||
@@ -228,6 +260,9 @@ final class UserModel: Model, @unchecked Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
func verifyPassword(_ password: String) throws -> Bool {
|
||||
try Bcrypt.verify(password, created: passwordHash)
|
||||
}
|
||||
}
|
||||
|
||||
final class UserTokenModel: Model, Codable, @unchecked Sendable {
|
||||
@@ -273,7 +308,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
||||
guard let user = try await UserModel.query(on: request.db)
|
||||
.filter(\UserModel.$username == basic.username)
|
||||
.first(),
|
||||
try Bcrypt.verify(basic.password, created: user.passwordHash)
|
||||
try user.verifyPassword(basic.password)
|
||||
else {
|
||||
throw Abort(.unauthorized)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ public extension SiteRoute {
|
||||
case create(User.Create)
|
||||
case get(id: User.ID)
|
||||
case index
|
||||
case resetPassword(id: User.ID, request: User.ResetPassword)
|
||||
case update(id: User.ID, updates: User.Update)
|
||||
|
||||
static let rootPath = "users"
|
||||
@@ -143,6 +144,11 @@ public extension SiteRoute {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.resetPassword(id:request:))) {
|
||||
Path { rootPath; User.ID.parser(); "reset-password" }
|
||||
Method.patch
|
||||
Body(.json(User.ResetPassword.self))
|
||||
}
|
||||
Route(.case(Self.update(id:updates:))) {
|
||||
Path { rootPath; User.ID.parser() }
|
||||
Method.patch
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -61,6 +61,19 @@ public extension User {
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetPassword: Codable, Equatable, Sendable {
|
||||
public let password: String
|
||||
public let confirmPassword: String
|
||||
|
||||
public init(
|
||||
password: String,
|
||||
confirmPassword: String
|
||||
) {
|
||||
self.password = password
|
||||
self.confirmPassword = confirmPassword
|
||||
}
|
||||
}
|
||||
|
||||
struct Token: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public let userID: User.ID
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,11 @@ extension HTMLAttribute.hx {
|
||||
get(SiteRoute.View.router.path(for: route))
|
||||
}
|
||||
|
||||
@Sendable
|
||||
static func patch(route: SiteRoute.View) -> HTMLAttribute {
|
||||
patch(SiteRoute.View.router.path(for: route))
|
||||
}
|
||||
|
||||
@Sendable
|
||||
static func post(route: SiteRoute.View) -> HTMLAttribute {
|
||||
post(SiteRoute.View.router.path(for: route))
|
||||
|
||||
@@ -9,6 +9,7 @@ struct UserDetail: HTML, Sendable {
|
||||
let user: User?
|
||||
|
||||
var content: some HTML {
|
||||
// TODO: Need a reset password form.
|
||||
Float(shouldDisplay: user != nil, resetURL: .user(.index)) {
|
||||
if let user {
|
||||
form(
|
||||
@@ -46,6 +47,13 @@ struct UserDetail: HTML, Sendable {
|
||||
.toggleContent(.float), .setWindowLocation(to: .user(.index))
|
||||
)
|
||||
)
|
||||
// TODO: trigger the reset password route.
|
||||
button(
|
||||
.class("btn-primary"),
|
||||
.hx.target(.id(.float)),
|
||||
.hx.get(route: .resetPassword(.index(id: user.id))),
|
||||
.hx.trigger(.event(.click))
|
||||
) { "Reset Password" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -20,21 +20,23 @@ struct UserForm: HTML, Sendable {
|
||||
form(
|
||||
.id(.user(.form)),
|
||||
.class("user-form"),
|
||||
.hx.post(route: context.targetURL),
|
||||
context.isResetPassword ? .hx.patch(route: context.targetURL) : .hx.post(route: context.targetURL),
|
||||
.hx.pushURL(context.pushURL),
|
||||
.hx.target(context.target),
|
||||
.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 {
|
||||
input(.type(.hidden), .name("next"), .value(next))
|
||||
}
|
||||
if context.showUsername {
|
||||
div(.class("row")) {
|
||||
input(.type(.text), .id("username"), .name("username"), .placeholder("Username"), .autofocus, .required)
|
||||
}
|
||||
}
|
||||
if context.showEmailInput {
|
||||
div(.class("row")) {
|
||||
input(.type(.email), .id("email"), .name("email"), .placeholder("Email"), .required)
|
||||
@@ -61,11 +63,41 @@ struct UserForm: HTML, Sendable {
|
||||
enum Context: Equatable, Sendable {
|
||||
case create
|
||||
case login(next: String?)
|
||||
case resetPassword(id: User.ID)
|
||||
|
||||
var isResetPassword: Bool {
|
||||
guard case .resetPassword = self else { return false }
|
||||
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
|
||||
case .login: return true
|
||||
case .resetPassword: return false
|
||||
}
|
||||
}
|
||||
|
||||
var showConfirmPassword: Bool {
|
||||
switch self {
|
||||
case .create: return true
|
||||
case .login: return false
|
||||
case .resetPassword: return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +105,7 @@ struct UserForm: HTML, Sendable {
|
||||
switch self {
|
||||
case .create: return true
|
||||
case .login: return false
|
||||
case .resetPassword: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +113,7 @@ struct UserForm: HTML, Sendable {
|
||||
switch self {
|
||||
case .create: return false
|
||||
case .login: return true
|
||||
case .resetPassword: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +123,8 @@ struct UserForm: HTML, Sendable {
|
||||
return "Create"
|
||||
case .login:
|
||||
return "Login"
|
||||
case .resetPassword:
|
||||
return "Reset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +134,8 @@ struct UserForm: HTML, Sendable {
|
||||
return .id(.user(.table))
|
||||
case .login:
|
||||
return .body
|
||||
case .resetPassword:
|
||||
return .id(.float)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +145,8 @@ struct UserForm: HTML, Sendable {
|
||||
return .user(.index)
|
||||
case .login:
|
||||
return .login(.index())
|
||||
case let .resetPassword(id: id):
|
||||
return .resetPassword(.index(id: id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
@@ -240,6 +259,7 @@ extension DatabaseClient.Users {
|
||||
get: { _ in User.mock },
|
||||
login: { _ in User.Token.mock },
|
||||
logout: { _ in },
|
||||
resetPassword: { _, _ in },
|
||||
token: { _ in User.Token.mock },
|
||||
update: { _, _ in User.mock }
|
||||
)
|
||||
@@ -407,3 +427,9 @@ extension PurchaseOrder.Create {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension User.ResetPassword {
|
||||
static var mock: Self {
|
||||
.init(password: "super-secret", confirmPassword: "super-secret")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -13,6 +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" hx-target="#float" hx-get="/reset-password/00000000-0000-0000-0000-000000000000" hx-trigger="click">Reset Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
Reference in New Issue
Block a user