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("")
+ }
+ })"""
+ ]
+ ]
+ ]