diff --git a/src/JobsJobsJobs/Data/Data.fs b/src/JobsJobsJobs/Data/Data.fs index 849a9b7..ede506d 100644 --- a/src/JobsJobsJobs/Data/Data.fs +++ b/src/JobsJobsJobs/Data/Data.fs @@ -30,18 +30,19 @@ module Table = open Npgsql.FSharp -/// Connection management for the Marten document store +/// Connection management for the document store module DataConnection = open Microsoft.Extensions.Configuration + open Npgsql - /// The configuration from which a document store will be created - let mutable private config : IConfiguration option = None + /// The data source for the document store + let mutable private dataSource : NpgsqlDataSource option = None /// Get the connection string let connection () = - match config with - | Some cfg -> Sql.connect (cfg.GetConnectionString "PostgreSQL") + match dataSource with + | Some ds -> ds.OpenConnection () |> Sql.existingConnection | None -> invalidOp "Connection.setUp() must be called before accessing the database" /// Create tables @@ -72,7 +73,7 @@ module DataConnection = /// Set up the data connection from the given configuration let setUp (cfg : IConfiguration) = backgroundTask { - config <- Some cfg + dataSource <- Some (NpgsqlDataSource.Create (cfg.GetConnectionString "PostgreSQL")) do! createTables () } diff --git a/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj b/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj index 153d84c..7274c80 100644 --- a/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj +++ b/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj @@ -1,7 +1,6 @@  - net6.0 true @@ -16,11 +15,10 @@ - - - - + + + diff --git a/src/JobsJobsJobs/Directory.Build.props b/src/JobsJobsJobs/Directory.Build.props index 48a6885..5f8aeb0 100644 --- a/src/JobsJobsJobs/Directory.Build.props +++ b/src/JobsJobsJobs/Directory.Build.props @@ -1,6 +1,6 @@ - net6.0 + net7.0 enable embedded 3.0.0.0 diff --git a/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj b/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj index 6c6db14..a3e6e03 100644 --- a/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj +++ b/src/JobsJobsJobs/Domain/JobsJobsJobs.Domain.fsproj @@ -14,9 +14,8 @@ - + - diff --git a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj index 95cafd4..c56b51a 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj +++ b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj @@ -2,7 +2,6 @@ Exe - net6.0 @@ -13,7 +12,6 @@ - diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 1ddc2ca..08c1f68 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -114,10 +114,94 @@ module Helpers = open System open JobsJobsJobs.Data +open JobsJobsJobs.ViewModels + +/// Handlers for /citizen routes +[] +module Citizen = + + open Microsoft.Extensions.Configuration + + /// Support module for /citizen routes + module private Support = + + /// The challenge questions and answers from the configuration + let mutable private challenges : (string * string)[] option = None + + /// The challenge questions and answers + let questions ctx = + match challenges with + | Some it -> it + | None -> + let qs = (config ctx).GetSection "ChallengeQuestions" + let qAndA = + seq { + for idx in 0..4 do + let section = qs.GetSection(string idx) + yield section["Question"], section["Answer"] + } + |> Array.ofSeq + challenges <- Some qAndA + qAndA + + // GET: /citizen/log-on + let logOn : HttpHandler = + render "Log On" (Citizen.logOn { ErrorMessage = None; Email = ""; Password = "" }) + + // GET: /citizen/register + let register : HttpHandler = fun next ctx -> task { + // Get two different indexes for NA-knowledge challenge questions + let q1Index = System.Random.Shared.Next(0, 5) + let mutable q2Index = System.Random.Shared.Next(0, 5) + while q1Index = q2Index do + q2Index <- System.Random.Shared.Next(0, 5) + let qAndA = Support.questions ctx + return! render "Register" + (Citizen.register (fst qAndA[q1Index]) (fst qAndA[q2Index]) + { RegisterViewModel.empty with Question1Index = q1Index; Question2Index = q2Index }) next ctx + } + + // POST: /citizen/register + let doRegistration : HttpHandler = fun next ctx -> task { + let! form = ctx.BindFormAsync () + // FIXME: stopped here; add validation + if form.Password.Length < 8 then + return! RequestErrors.BAD_REQUEST "Password out of range" next ctx + else + let now = now ctx + let noPass = + { Citizen.empty with + Id = CitizenId.create () + Email = form.Email + FirstName = form.FirstName + LastName = form.LastName + DisplayName = noneIfBlank form.DisplayName + JoinedOn = now + LastSeenOn = now + } + let citizen = { noPass with PasswordHash = Auth.Passwords.hash noPass form.Password } + let security = + { SecurityInfo.empty with + Id = citizen.Id + AccountLocked = true + Token = Some (Auth.createToken citizen) + TokenUsage = Some "confirm" + TokenExpires = Some (now + (Duration.FromDays 3)) + } + let! success = Citizens.register citizen security + if success then + let! emailResponse = Email.sendAccountConfirmation citizen security + let logFac = logger ctx + let log = logFac.CreateLogger "JobsJobsJobs.Api.Handlers.Citizen" + log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}" + return! ok next ctx + else + return! RequestErrors.CONFLICT "" next ctx + } /// Handlers for /api/citizen routes [] -module Citizen = +module CitizenApi = // POST: /api/citizen/register let register : HttpHandler = fun next ctx -> task { @@ -510,23 +594,30 @@ open Giraffe.EndpointRouting /// All available endpoints for the application let allEndpoints = [ GET_HEAD [ route "/" Home.home ] + subRoute "/citizen" [ + GET_HEAD [ + route "/log-on" Citizen.logOn + route "/register" Citizen.register + ] + POST [ route "/register" Citizen.doRegistration ] + ] GET_HEAD [ route "/how-it-works" Home.howItWorks ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] GET_HEAD [ route "/terms-of-service" Home.termsOfService ] subRoute "/api" [ subRoute "/citizen" [ - GET_HEAD [ routef "/%O" Citizen.get ] + GET_HEAD [ routef "/%O" CitizenApi.get ] PATCH [ - route "/account" Citizen.account - route "/confirm" Citizen.confirmToken + route "/account" CitizenApi.account + route "/confirm" CitizenApi.confirmToken ] POST [ - route "/log-on" Citizen.logOn - route "/register" Citizen.register + route "/log-on" CitizenApi.logOn + route "/register" CitizenApi.register ] DELETE [ - route "" Citizen.delete - route "/deny" Citizen.denyToken + route "" CitizenApi.delete + route "/deny" CitizenApi.denyToken ] ] GET_HEAD [ route "/continents" Continent.all ] diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj index b9859c9..705864d 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj @@ -34,11 +34,10 @@ - + - diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 439358a..061a83b 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -12,3 +12,50 @@ type LogOnViewModel = /// The password of the user attempting to log on Password : string } + + +/// View model for the registration page +type RegisterViewModel = + { /// The user's first name + FirstName : string + + /// The user's last name + LastName : string + + /// The user's display name + DisplayName : string option + + /// The user's e-mail address + Email : string + + /// The user's desired password + Password : string + + /// The index of the first question asked + Question1Index : int + + /// The answer for the first question asked + Question1Answer : string + + /// The index of the second question asked + Question2Index : int + + /// The answer for the second question asked + Question2Answer : string + } + +/// Support for the registration page view model +module RegisterViewModel = + + /// An empty view model + let empty = + { FirstName = "" + LastName = "" + DisplayName = None + Email = "" + Password = "" + Question1Index = 0 + Question1Answer = "" + Question2Index = 0 + Question2Answer = "" + } diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index 645f696..68b33f7 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -3,6 +3,7 @@ module JobsJobsJobs.Views.Citizen open Giraffe.ViewEngine +open Giraffe.ViewEngine.Htmx open JobsJobsJobs.ViewModels /// The log on page @@ -20,29 +21,29 @@ let logOn (m : LogOnViewModel) = rawText " before you may log on." ] | None -> () - form [ _class "row g-3 pb-3"] [ + form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [ div [ _class "col-12 col-md-6" ] [ div [ _class "form-floating" ] [ input [ _type "email" - _id "email" _class "form-control" + _id (nameof m.Email) _name (nameof m.Email) _placeholder "E-mail Address" _value m.Email _required _autofocus ] - label [ _class "jjj-required"; _for "email" ] [ rawText "E-mail Address" ] + label [ _class "jjj-required"; _for (nameof m.Email) ] [ rawText "E-mail Address" ] ] ] div [ _class "col-12 col-md-6" ] [ div [ _class "form-floating" ] [ input [ _type "password" - _id "password" _class "form-control" + _id (nameof m.Password) _name (nameof m.Password) _placeholder "Password" _required ] - label [ _class "jjj-required"; _for "password" ] [ rawText "Password" ] + label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ] ] ] div [ _class "col-12" ] [ @@ -58,3 +59,97 @@ let logOn (m : LogOnViewModel) = rawText "Forgot your password? "; a [ _href "/citizen/forgot-password" ] [ rawText "Request a reset." ] ] ] + +/// The registration page +let register q1 q2 (m : RegisterViewModel) = + article [] [ + h3 [ _class "pb-3" ] [ rawText "Register" ] + form [ _class "row g-3"; _hxPost "/citizen/register" ] [ + div [ _class "col-6 col-xl-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName) + _value m.FirstName; _placeholder "First Name"; _required; _autofocus ] + label [ _class "jjj-required"; _for (nameof m.FirstName) ] [ rawText "First Name" ] + ] + ] + div [ _class "col-6 col-xl-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _class "form-control"; _id (nameof m.LastName); _name (nameof m.LastName) + _value m.LastName; _placeholder "Last Name"; _required ] + label [ _class "jjj-required"; _for (nameof m.LastName) ] [ rawText "Last Name" ] + ] + ] + div [ _class "col-6 col-xl-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _class "form-control"; _id (nameof m.DisplayName) + _name (nameof m.DisplayName); _value (defaultArg m.DisplayName "") + _placeholder "Display Name" ] + label [ _for (nameof m.DisplayName) ] [ rawText "Display Name" ] + div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ] + ] + ] + div [ _class "col-6 col-xl-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "email"; _class "form-control"; _id (nameof m.Email); _name (nameof m.Email) + _value m.Email; _placeholder "E-mail Address"; _required ] + label [ _class "jjj-required"; _for (nameof m.Email) ] [ rawText "E-mail Address" ] + ] + ] + div [ _class "col-6 col-xl-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "password"; _class "form-control"; _id (nameof m.Password); _name (nameof m.Password) + _placeholder "Password"; _minlength "8"; _required ] + label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ] + ] + ] + div [ _class "col-6 col-xl-4" ] [ + div [ _class "form-floating" ] [ + input [ _type "password"; _class "form-control"; _id "ConfirmPassword" + _placeholder "Confirm Password"; _minlength "8"; _required ] + label [ _class "jjj-required"; _for "ConfirmPassword" ] [ rawText "Confirm Password" ] + ] + ] + div [ _class "col-12" ] [ + hr [] + p [ _class "mb-0 text-muted fst-italic" ] [ + rawText "Before your account request is through, you must answer these questions two…" + ] + ] + div [ _class "col-12 col-xl-6" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _class "form-control"; _id (nameof m.Question1Answer) + _name (nameof m.Question1Answer); _value m.Question1Answer; _placeholder "Question 1" + _maxlength "30"; _required ] + label [ _class "jjj-required"; _for (nameof m.Question1Answer) ] [ str q1 ] + ] + input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ] + ] + div [ _class "col-12 col-xl-6" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _class "form-control"; _id (nameof m.Question2Answer) + _name (nameof m.Question2Answer); _value m.Question2Answer; _placeholder "Question 2" + _maxlength "30"; _required ] + label [ _class "jjj-required"; _for (nameof m.Question2Answer) ] [ str q2 ] + ] + input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ] + ] + div [ _class "col-12" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ + i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" + ] + ] + script [] [ rawText """ + const pw = document.getElementById("Password") + const pwConfirm = document.getElementById("ConfirmPassword") + pwConfirm.addEventListener("input", () => { + if (!pw.validity.valid) { + pwConfirm.setCustomValidity("") + } else if (!pwConfirm.validity.valueMissing && pw.value !== pwConfirm.value) { + pwConfirm.setCustomValidity("Confirmation password does not match") + } else { + pwConfirm.setCustomValidity("") + } + })""" + ] + ] + ]