Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
9 changed files with 259 additions and 31 deletions
Showing only changes of commit 5f7863300c - Show all commits

View File

@ -30,18 +30,19 @@ module Table =
open Npgsql.FSharp open Npgsql.FSharp
/// Connection management for the Marten document store /// Connection management for the document store
module DataConnection = module DataConnection =
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Npgsql
/// The configuration from which a document store will be created /// The data source for the document store
let mutable private config : IConfiguration option = None let mutable private dataSource : NpgsqlDataSource option = None
/// Get the connection string /// Get the connection string
let connection () = let connection () =
match config with match dataSource with
| Some cfg -> Sql.connect (cfg.GetConnectionString "PostgreSQL") | Some ds -> ds.OpenConnection () |> Sql.existingConnection
| None -> invalidOp "Connection.setUp() must be called before accessing the database" | None -> invalidOp "Connection.setUp() must be called before accessing the database"
/// Create tables /// Create tables
@ -72,7 +73,7 @@ module DataConnection =
/// Set up the data connection from the given configuration /// Set up the data connection from the given configuration
let setUp (cfg : IConfiguration) = backgroundTask { let setUp (cfg : IConfiguration) = backgroundTask {
config <- Some cfg dataSource <- Some (NpgsqlDataSource.Create (cfg.GetConnectionString "PostgreSQL"))
do! createTables () do! createTables ()
} }

View File

@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
@ -16,11 +15,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FSharp.SystemTextJson" Version="0.19.13" /> <PackageReference Include="FSharp.SystemTextJson" Version="0.19.13" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
<PackageReference Include="Npgsql" Version="6.0.6" /> <PackageReference Include="Npgsql" Version="7.0.0" />
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" /> <PackageReference Include="Npgsql.FSharp" Version="5.5.0" />
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" /> <PackageReference Include="Npgsql.NodaTime" Version="7.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
<AssemblyVersion>3.0.0.0</AssemblyVersion> <AssemblyVersion>3.0.0.0</AssemblyVersion>

View File

@ -14,9 +14,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Markdig" Version="0.30.3" /> <PackageReference Include="Markdig" Version="0.30.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" />
<PackageReference Include="NodaTime" Version="3.1.2" /> <PackageReference Include="NodaTime" Version="3.1.2" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -2,7 +2,6 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -13,7 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" /> <PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -114,10 +114,94 @@ module Helpers =
open System open System
open JobsJobsJobs.Data open JobsJobsJobs.Data
open JobsJobsJobs.ViewModels
/// Handlers for /citizen routes
[<RequireQualifiedAccess>]
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<RegisterViewModel> ()
// 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 /// Handlers for /api/citizen routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Citizen = module CitizenApi =
// POST: /api/citizen/register // POST: /api/citizen/register
let register : HttpHandler = fun next ctx -> task { let register : HttpHandler = fun next ctx -> task {
@ -510,23 +594,30 @@ open Giraffe.EndpointRouting
/// All available endpoints for the application /// All available endpoints for the application
let allEndpoints = [ let allEndpoints = [
GET_HEAD [ route "/" Home.home ] 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 "/how-it-works" Home.howItWorks ]
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
GET_HEAD [ route "/terms-of-service" Home.termsOfService ] GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
subRoute "/api" [ subRoute "/api" [
subRoute "/citizen" [ subRoute "/citizen" [
GET_HEAD [ routef "/%O" Citizen.get ] GET_HEAD [ routef "/%O" CitizenApi.get ]
PATCH [ PATCH [
route "/account" Citizen.account route "/account" CitizenApi.account
route "/confirm" Citizen.confirmToken route "/confirm" CitizenApi.confirmToken
] ]
POST [ POST [
route "/log-on" Citizen.logOn route "/log-on" CitizenApi.logOn
route "/register" Citizen.register route "/register" CitizenApi.register
] ]
DELETE [ DELETE [
route "" Citizen.delete route "" CitizenApi.delete
route "/deny" Citizen.denyToken route "/deny" CitizenApi.denyToken
] ]
] ]
GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/continents" Continent.all ]

View File

@ -34,11 +34,10 @@
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" /> <PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.4" /> <PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.4" />
<PackageReference Include="MailKit" Version="3.3.0" /> <PackageReference Include="MailKit" Version="3.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.0" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" /> <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.22.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.22.0" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -12,3 +12,50 @@ type LogOnViewModel =
/// The password of the user attempting to log on /// The password of the user attempting to log on
Password : string 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 = ""
}

View File

@ -3,6 +3,7 @@
module JobsJobsJobs.Views.Citizen module JobsJobsJobs.Views.Citizen
open Giraffe.ViewEngine open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.ViewModels open JobsJobsJobs.ViewModels
/// The log on page /// The log on page
@ -20,29 +21,29 @@ let logOn (m : LogOnViewModel) =
rawText " before you may log on." rawText " before you may log on."
] ]
| None -> () | 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 "col-12 col-md-6" ] [
div [ _class "form-floating" ] [ div [ _class "form-floating" ] [
input [ _type "email" input [ _type "email"
_id "email"
_class "form-control" _class "form-control"
_id (nameof m.Email)
_name (nameof m.Email) _name (nameof m.Email)
_placeholder "E-mail Address" _placeholder "E-mail Address"
_value m.Email _value m.Email
_required _required
_autofocus ] _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 "col-12 col-md-6" ] [
div [ _class "form-floating" ] [ div [ _class "form-floating" ] [
input [ _type "password" input [ _type "password"
_id "password"
_class "form-control" _class "form-control"
_id (nameof m.Password)
_name (nameof m.Password) _name (nameof m.Password)
_placeholder "Password" _placeholder "Password"
_required ] _required ]
label [ _class "jjj-required"; _for "password" ] [ rawText "Password" ] label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ]
] ]
] ]
div [ _class "col-12" ] [ 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." ] 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&hellip;"
]
]
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 "&nbsp; 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("")
}
})"""
]
]
]