Version 3 #40
@ -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 ()
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@ -16,11 +15,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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="Npgsql" Version="6.0.6" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.0" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.5.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<DebugType>embedded</DebugType>
|
||||
<AssemblyVersion>3.0.0.0</AssemblyVersion>
|
||||
|
@ -14,9 +14,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<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 Update="FSharp.Core" Version="6.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -13,7 +12,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -114,10 +114,94 @@ module Helpers =
|
||||
|
||||
open System
|
||||
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
|
||||
[<RequireQualifiedAccess>]
|
||||
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 ]
|
||||
|
@ -34,11 +34,10 @@
|
||||
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.4" />
|
||||
<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="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.22.0" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -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 = ""
|
||||
}
|
||||
|
@ -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("")
|
||||
}
|
||||
})"""
|
||||
]
|
||||
]
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user