Version 3 (#40)
Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
279
src/JobsJobsJobs/Citizens/Data.fs
Normal file
279
src/JobsJobsJobs/Citizens/Data.fs
Normal file
@@ -0,0 +1,279 @@
|
||||
module JobsJobsJobs.Citizens.Data
|
||||
|
||||
open JobsJobsJobs.Common.Data
|
||||
open JobsJobsJobs.Domain
|
||||
open NodaTime
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// The last time a token purge check was run
|
||||
let mutable private lastPurge = Instant.MinValue
|
||||
|
||||
/// Lock access to the above
|
||||
let private locker = obj ()
|
||||
|
||||
/// Delete a citizen by their ID using the given connection properties
|
||||
let private doDeleteById citizenId connProps = backgroundTask {
|
||||
let! _ =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id;
|
||||
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id;
|
||||
DELETE FROM {Table.Citizen} WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Delete a citizen by their ID
|
||||
let deleteById citizenId =
|
||||
doDeleteById citizenId (dataSource ())
|
||||
|
||||
/// Save a citizen
|
||||
let private saveCitizen (citizen : Citizen) connProps =
|
||||
saveDocument Table.Citizen (CitizenId.toString citizen.Id) connProps (mkDoc citizen)
|
||||
|
||||
/// Save security information for a citizen
|
||||
let private saveSecurity (security : SecurityInfo) connProps =
|
||||
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security)
|
||||
|
||||
/// Purge expired tokens
|
||||
let private purgeExpiredTokens now = backgroundTask {
|
||||
let connProps = dataSource ()
|
||||
let! info =
|
||||
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
|
||||
if expired.TokenUsage.Value = "confirm" then
|
||||
// Unconfirmed account; delete the entire thing
|
||||
do! doDeleteById expired.Id connProps
|
||||
else
|
||||
// Some other use; just clear the token
|
||||
do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps
|
||||
}
|
||||
|
||||
/// Check for tokens to purge if it's been more than 10 minutes since we last checked
|
||||
let private checkForPurge skipCheck =
|
||||
lock locker (fun () -> backgroundTask {
|
||||
let now = SystemClock.Instance.GetCurrentInstant ()
|
||||
if skipCheck || (now - lastPurge).TotalMinutes >= 10 then
|
||||
do! purgeExpiredTokens now
|
||||
lastPurge <- now
|
||||
})
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById citizenId = backgroundTask {
|
||||
match! dataSource () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
|
||||
| Some c when not c.IsLegacy -> return Some c
|
||||
| Some _
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Save a citizen
|
||||
let save citizen =
|
||||
saveCitizen citizen (dataSource ())
|
||||
|
||||
/// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
|
||||
let register citizen (security : SecurityInfo) = backgroundTask {
|
||||
let connProps = dataSource ()
|
||||
use conn = Sql.createConnection connProps
|
||||
use! txn = conn.BeginTransactionAsync ()
|
||||
try
|
||||
do! saveCitizen citizen connProps
|
||||
do! saveSecurity security connProps
|
||||
do! txn.CommitAsync ()
|
||||
return true
|
||||
with
|
||||
| :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" ->
|
||||
do! txn.RollbackAsync ()
|
||||
return false
|
||||
}
|
||||
|
||||
/// Try to find the security information matching a confirmation token
|
||||
let private tryConfirmToken token connProps = backgroundTask {
|
||||
let! tryInfo =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
SELECT *
|
||||
FROM {Table.SecurityInfo}
|
||||
WHERE data ->> 'token' = @token
|
||||
AND data ->> 'tokenUsage' = 'confirm'"
|
||||
|> Sql.parameters [ "@token", Sql.string token ]
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
return List.tryHead tryInfo
|
||||
}
|
||||
|
||||
/// Confirm a citizen's account
|
||||
let confirmAccount token = backgroundTask {
|
||||
do! checkForPurge true
|
||||
let connProps = dataSource ()
|
||||
match! tryConfirmToken token connProps with
|
||||
| Some info ->
|
||||
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
|
||||
connProps
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent)
|
||||
let denyAccount token = backgroundTask {
|
||||
do! checkForPurge true
|
||||
let connProps = dataSource ()
|
||||
match! tryConfirmToken token connProps with
|
||||
| Some info ->
|
||||
do! doDeleteById info.Id connProps
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Attempt a user log on
|
||||
let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string)
|
||||
now = backgroundTask {
|
||||
do! checkForPurge false
|
||||
let connProps = dataSource ()
|
||||
let! tryCitizen =
|
||||
connProps
|
||||
|> Sql.query $"
|
||||
SELECT *
|
||||
FROM {Table.Citizen}
|
||||
WHERE data ->> 'email' = @email
|
||||
AND data ->> 'isLegacy' = 'false'"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
match List.tryHead tryCitizen with
|
||||
| Some citizen ->
|
||||
let citizenId = CitizenId.toString citizen.Id
|
||||
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps
|
||||
let! info = backgroundTask {
|
||||
match tryInfo with
|
||||
| Some it -> return it
|
||||
| None ->
|
||||
let it = { SecurityInfo.empty with Id = citizen.Id }
|
||||
do! saveSecurity it connProps
|
||||
return it
|
||||
}
|
||||
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
|
||||
else
|
||||
match pwVerify citizen password with
|
||||
| Some rehash ->
|
||||
let hash = if rehash then pwHash citizen password else citizen.PasswordHash
|
||||
do! saveSecurity { info with FailedLogOnAttempts = 0 } connProps
|
||||
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash } connProps
|
||||
return Ok { citizen with LastSeenOn = now }
|
||||
| None ->
|
||||
let locked = info.FailedLogOnAttempts >= 4
|
||||
do! { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|
||||
|> saveSecurity <| connProps
|
||||
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
|
||||
| None -> return Error "Log on unsuccessful"
|
||||
}
|
||||
|
||||
/// Try to retrieve a citizen and their security information by their e-mail address
|
||||
let tryByEmailWithSecurity email = backgroundTask {
|
||||
let toCitizenSecurityPair row = (toDocument<Citizen> row, toDocumentFrom<SecurityInfo> "sec_data" row)
|
||||
let! results =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT c.*, s.data AS sec_data
|
||||
FROM {Table.Citizen} c
|
||||
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
|
||||
WHERE c.data ->> 'email' = @email"
|
||||
|> Sql.parameters [ "@email", Sql.string email ]
|
||||
|> Sql.executeAsync toCitizenSecurityPair
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Save an updated security information document
|
||||
let saveSecurityInfo security = backgroundTask {
|
||||
do! saveSecurity security (dataSource ())
|
||||
}
|
||||
|
||||
/// Try to retrieve security information by the given token
|
||||
let trySecurityByToken token = backgroundTask {
|
||||
do! checkForPurge false
|
||||
let! results =
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'token' = @token"
|
||||
|> Sql.parameters [ "@token", Sql.string token ]
|
||||
|> Sql.executeAsync toDocument<SecurityInfo>
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
/// Get all legacy citizens
|
||||
let legacy () = backgroundTask {
|
||||
return!
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.Citizen} WHERE data ->> 'isLegacy' = 'true' ORDER BY data ->> 'firstName'"
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
}
|
||||
|
||||
/// Get all current citizens with verified accounts but without a profile
|
||||
let current () = backgroundTask {
|
||||
return!
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
SELECT c.*
|
||||
FROM {Table.Citizen} c
|
||||
INNER JOIN {Table.SecurityInfo} si ON si.id = c.id
|
||||
WHERE c.data ->> 'isLegacy' = 'false'
|
||||
AND si.data ->> 'accountLocked' = 'false'
|
||||
AND NOT EXISTS (SELECT 1 FROM {Table.Profile} p WHERE p.id = c.id)"
|
||||
|> Sql.executeAsync toDocument<Citizen>
|
||||
}
|
||||
|
||||
let migrateLegacy currentId legacyId = backgroundTask {
|
||||
let oldId = CitizenId.toString legacyId
|
||||
let connProps = dataSource ()
|
||||
use conn = Sql.createConnection connProps
|
||||
use! txn = conn.BeginTransactionAsync ()
|
||||
try
|
||||
// Add legacy data to current user
|
||||
let! profiles =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Profile} WHERE id = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Profile>
|
||||
match List.tryHead profiles with
|
||||
| Some profile ->
|
||||
do! saveDocument
|
||||
Table.Profile (CitizenId.toString currentId) (Sql.existingConnection conn)
|
||||
(mkDoc { profile with Id = currentId; IsLegacy = false })
|
||||
| None -> ()
|
||||
let! listings =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Listing>
|
||||
for listing in listings do
|
||||
let newListing = { listing with Id = ListingId.create (); CitizenId = currentId; IsLegacy = false }
|
||||
do! saveDocument
|
||||
Table.Listing (ListingId.toString newListing.Id) (Sql.existingConnection conn) (mkDoc newListing)
|
||||
let! successes =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"SELECT * FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeAsync toDocument<Success>
|
||||
for success in successes do
|
||||
let newSuccess = { success with Id = SuccessId.create (); CitizenId = currentId }
|
||||
do! saveDocument
|
||||
Table.Success (SuccessId.toString newSuccess.Id) (Sql.existingConnection conn) (mkDoc newSuccess)
|
||||
// Delete legacy data
|
||||
let! _ =
|
||||
conn
|
||||
|> Sql.existingConnection
|
||||
|> Sql.query $"
|
||||
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId;
|
||||
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId;
|
||||
DELETE FROM {Table.Citizen} WHERE id = @oldId"
|
||||
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
do! txn.CommitAsync ()
|
||||
return Ok ""
|
||||
with :? Npgsql.PostgresException as ex ->
|
||||
do! txn.RollbackAsync ()
|
||||
return Error ex.MessageText
|
||||
}
|
||||
164
src/JobsJobsJobs/Citizens/Domain.fs
Normal file
164
src/JobsJobsJobs/Citizens/Domain.fs
Normal file
@@ -0,0 +1,164 @@
|
||||
module JobsJobsJobs.Citizens.Domain
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// The data to add or update an other contact
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type OtherContactForm =
|
||||
{ /// The type of the contact
|
||||
ContactType : string
|
||||
|
||||
/// The name of the contact
|
||||
Name : string
|
||||
|
||||
/// The value of the contact (URL, e-mail address, phone, etc.)
|
||||
Value : string
|
||||
|
||||
/// Whether this contact is displayed for public employment profiles and job listings
|
||||
IsPublic : bool
|
||||
}
|
||||
|
||||
/// Support functions for the contact form
|
||||
module OtherContactForm =
|
||||
|
||||
/// Create a contact form from a contact
|
||||
let fromContact (contact : OtherContact) =
|
||||
{ ContactType = ContactType.toString contact.ContactType
|
||||
Name = defaultArg contact.Name ""
|
||||
Value = contact.Value
|
||||
IsPublic = contact.IsPublic
|
||||
}
|
||||
|
||||
|
||||
/// The data available to update an account profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type AccountProfileForm =
|
||||
{ /// The first name of the citizen
|
||||
FirstName : string
|
||||
|
||||
/// The last name of the citizen
|
||||
LastName : string
|
||||
|
||||
/// The display name for the citizen
|
||||
DisplayName : string
|
||||
|
||||
/// The citizen's new password
|
||||
NewPassword : string
|
||||
|
||||
/// Confirmation of the citizen's new password
|
||||
NewPasswordConfirm : string
|
||||
|
||||
/// The contacts for this profile
|
||||
Contacts : OtherContactForm array
|
||||
}
|
||||
|
||||
/// Support functions for the account profile form
|
||||
module AccountProfileForm =
|
||||
|
||||
/// Create an account profile form from a citizen
|
||||
let fromCitizen (citizen : Citizen) =
|
||||
{ FirstName = citizen.FirstName
|
||||
LastName = citizen.LastName
|
||||
DisplayName = defaultArg citizen.DisplayName ""
|
||||
NewPassword = ""
|
||||
NewPasswordConfirm = ""
|
||||
Contacts = citizen.OtherContacts |> List.map OtherContactForm.fromContact |> Array.ofList
|
||||
}
|
||||
|
||||
|
||||
/// Form for the forgot / reset password page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ForgotPasswordForm =
|
||||
{ /// The e-mail address for the account wishing to reset their password
|
||||
Email : string
|
||||
}
|
||||
|
||||
|
||||
/// Form for the log on page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LogOnForm =
|
||||
{ /// A message regarding an error encountered during a log on attempt
|
||||
ErrorMessage : string option
|
||||
|
||||
/// The e-mail address for the user attempting to log on
|
||||
Email : string
|
||||
|
||||
/// The password of the user attempting to log on
|
||||
Password : string
|
||||
|
||||
/// The URL where the user should be redirected after logging on
|
||||
ReturnTo : string option
|
||||
}
|
||||
|
||||
|
||||
/// Form for the registration page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type RegisterForm =
|
||||
{ /// 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 RegisterForm =
|
||||
|
||||
/// An empty view model
|
||||
let empty =
|
||||
{ FirstName = ""
|
||||
LastName = ""
|
||||
DisplayName = None
|
||||
Email = ""
|
||||
Password = ""
|
||||
Question1Index = 0
|
||||
Question1Answer = ""
|
||||
Question2Index = 0
|
||||
Question2Answer = ""
|
||||
}
|
||||
|
||||
|
||||
/// The form for a user resetting their password
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ResetPasswordForm =
|
||||
{ /// The ID of the citizen whose password is being reset
|
||||
Id : string
|
||||
|
||||
/// The verification token for the password reset
|
||||
Token : string
|
||||
|
||||
/// The new password for the account
|
||||
Password : string
|
||||
}
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~ //
|
||||
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LegacyMigrationForm =
|
||||
{ /// The ID of the current citizen
|
||||
Id : string
|
||||
|
||||
/// The ID of the legacy citizen to be migrated
|
||||
LegacyId : string
|
||||
}
|
||||
383
src/JobsJobsJobs/Citizens/Handlers.fs
Normal file
383
src/JobsJobsJobs/Citizens/Handlers.fs
Normal file
@@ -0,0 +1,383 @@
|
||||
module JobsJobsJobs.Citizens.Handlers
|
||||
|
||||
open System
|
||||
open System.Security.Claims
|
||||
open Giraffe
|
||||
open JobsJobsJobs
|
||||
open JobsJobsJobs.Citizens.Domain
|
||||
open JobsJobsJobs.Common.Handlers
|
||||
open JobsJobsJobs.Domain
|
||||
open Microsoft.AspNetCore.Authentication
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.Extensions.Logging
|
||||
open NodaTime
|
||||
|
||||
/// Authorization functions
|
||||
module private Auth =
|
||||
|
||||
open System.Text
|
||||
|
||||
/// Create a confirmation or password reset token for a user
|
||||
let createToken (citizen : Citizen) =
|
||||
Convert.ToBase64String (Guid.NewGuid().ToByteArray () |> Array.append (Encoding.UTF8.GetBytes citizen.Email))
|
||||
|
||||
/// 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"].ToLowerInvariant ())
|
||||
}
|
||||
|> Array.ofSeq
|
||||
challenges <- Some qAndA
|
||||
qAndA
|
||||
|
||||
/// Password hashing and verification
|
||||
module Passwords =
|
||||
|
||||
open Microsoft.AspNetCore.Identity
|
||||
|
||||
/// The password hasher to use for the application
|
||||
let private hasher = PasswordHasher<Citizen> ()
|
||||
|
||||
/// Hash a password for a user
|
||||
let hash citizen password =
|
||||
hasher.HashPassword (citizen, password)
|
||||
|
||||
/// Verify a password (returns true if the password needs to be rehashed)
|
||||
let verify citizen password =
|
||||
match hasher.VerifyHashedPassword (citizen, citizen.PasswordHash, password) with
|
||||
| PasswordVerificationResult.Success -> Some false
|
||||
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
|
||||
| _ -> None
|
||||
|
||||
/// Require an administrative user (used for legacy migration endpoints)
|
||||
let requireAdmin : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let adminUser = (config ctx)["AdminUser"]
|
||||
if adminUser = defaultArg (tryUser ctx) "" then return! next ctx
|
||||
else return! Error.notAuthorized next ctx
|
||||
}
|
||||
|
||||
|
||||
// GET: /citizen/account
|
||||
let account : HttpHandler = fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some citizen ->
|
||||
return!
|
||||
Views.account (AccountProfileForm.fromCitizen citizen) (isHtmx ctx) (csrf ctx)
|
||||
|> render "Account Profile" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/cancel-reset/[token]
|
||||
let cancelReset token : HttpHandler = fun next ctx -> task {
|
||||
let! wasCanceled = task {
|
||||
match! Data.trySecurityByToken token with
|
||||
| Some security ->
|
||||
do! Data.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None }
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
return! Views.resetCanceled wasCanceled |> render "Password Reset Cancellation" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/confirm/[token]
|
||||
let confirm token : HttpHandler = fun next ctx -> task {
|
||||
let! isConfirmed = Data.confirmAccount token
|
||||
return! Views.confirmAccount isConfirmed |> render "Account Confirmation" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/dashboard
|
||||
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! citizen = Data.findById citizenId
|
||||
let! profile = Profiles.Data.findById citizenId
|
||||
let! prfCount = Profiles.Data.count ()
|
||||
return! Views.dashboard citizen.Value profile prfCount (timeZone ctx) |> render "Dashboard" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/delete
|
||||
let delete : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
do! Data.deleteById (currentCitizenId ctx)
|
||||
do! ctx.SignOutAsync ()
|
||||
return! render "Account Deleted Successfully" next ctx Views.deleted
|
||||
}
|
||||
|
||||
// GET: /citizen/deny/[token]
|
||||
let deny token : HttpHandler = fun next ctx -> task {
|
||||
let! wasDeleted = Data.denyAccount token
|
||||
return! Views.denyAccount wasDeleted |> render "Account Deletion" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/forgot-password
|
||||
let forgotPassword : HttpHandler = fun next ctx ->
|
||||
Views.forgotPassword (csrf ctx) |> render "Forgot Password" next ctx
|
||||
|
||||
// POST: /citizen/forgot-password
|
||||
let doForgotPassword : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<ForgotPasswordForm> ()
|
||||
match! Data.tryByEmailWithSecurity form.Email with
|
||||
| Some (citizen, security) ->
|
||||
let withToken =
|
||||
{ security with
|
||||
Token = Some (Auth.createToken citizen)
|
||||
TokenUsage = Some "reset"
|
||||
TokenExpires = Some (now ctx + (Duration.FromDays 3))
|
||||
}
|
||||
do! Data.saveSecurityInfo withToken
|
||||
let! emailResponse = Email.sendPasswordReset citizen withToken
|
||||
let logFac = logger ctx
|
||||
let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen"
|
||||
log.LogInformation $"Password reset e-mail for {citizen.Email} received {emailResponse}"
|
||||
| None -> ()
|
||||
return! Views.forgotPasswordSent form |> render "Reset Request Processed" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/log-off
|
||||
let logOff : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||
do! addSuccess "Log off successful" ctx
|
||||
return! redirectToGet "/" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/log-on
|
||||
let logOn : HttpHandler = fun next ctx ->
|
||||
let returnTo =
|
||||
if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
|
||||
Views.logOn { ErrorMessage = None; Email = ""; Password = ""; ReturnTo = returnTo } (csrf ctx)
|
||||
|> render "Log On" next ctx
|
||||
|
||||
|
||||
// POST: /citizen/log-on
|
||||
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<LogOnForm> ()
|
||||
match! Data.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
|
||||
| Ok citizen ->
|
||||
let claims = seq {
|
||||
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.Id)
|
||||
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||
}
|
||||
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity,
|
||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||
do! addSuccess "Log on successful" ctx
|
||||
return! redirectToGet (defaultArg form.ReturnTo "/citizen/dashboard") next ctx
|
||||
| Error msg ->
|
||||
do! addError msg ctx
|
||||
return! Views.logOn { form with Password = "" } (csrf ctx) |> render "Log On" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/register
|
||||
let register next ctx =
|
||||
// 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 = Auth.questions ctx
|
||||
Views.register (fst qAndA[q1Index]) (fst qAndA[q2Index])
|
||||
{ RegisterForm.empty with Question1Index = q1Index; Question2Index = q2Index } (isHtmx ctx) (csrf ctx)
|
||||
|> render "Register" next ctx
|
||||
|
||||
// POST: /citizen/register
|
||||
#nowarn "3511"
|
||||
let doRegistration : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<RegisterForm> ()
|
||||
let qAndA = Auth.questions ctx
|
||||
let mutable badForm = false
|
||||
let errors = [
|
||||
if form.FirstName.Length < 1 then "First name is required"
|
||||
if form.LastName.Length < 1 then "Last name is required"
|
||||
if form.Email.Length < 1 then "E-mail address is required"
|
||||
if form.Password.Length < 8 then "Password is too short"
|
||||
if form.Question1Index < 0 || form.Question1Index > 4
|
||||
|| form.Question2Index < 0 || form.Question2Index > 4
|
||||
|| form.Question1Index = form.Question2Index then
|
||||
badForm <- true
|
||||
else if (snd qAndA[form.Question1Index]) <> (form.Question1Answer.Trim().ToLowerInvariant ())
|
||||
|| (snd qAndA[form.Question2Index]) <> (form.Question2Answer.Trim().ToLowerInvariant ()) then
|
||||
"Question answers are incorrect"
|
||||
]
|
||||
let refreshPage () =
|
||||
Views.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index]) { form with Password = "" }
|
||||
(isHtmx ctx) (csrf ctx)
|
||||
|> renderHandler "Register"
|
||||
|
||||
if badForm then
|
||||
do! addError "The form posted was invalid; please complete it again" ctx
|
||||
return! register next ctx
|
||||
else if List.isEmpty errors then
|
||||
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 = Data.register citizen security
|
||||
if success then
|
||||
let! emailResponse = Email.sendAccountConfirmation citizen security
|
||||
let logFac = logger ctx
|
||||
let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen"
|
||||
log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}"
|
||||
return! Views.registered |> render "Registration Successful" next ctx
|
||||
else
|
||||
do! addError "There is already an account registered to the e-mail address provided" ctx
|
||||
return! refreshPage () next ctx
|
||||
else
|
||||
do! addErrors errors ctx
|
||||
return! refreshPage () next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/reset-password/[token]
|
||||
let resetPassword token : HttpHandler = fun next ctx -> task {
|
||||
match! Data.trySecurityByToken token with
|
||||
| Some security ->
|
||||
return!
|
||||
Views.resetPassword { Id = CitizenId.toString security.Id; Token = token; Password = "" } (isHtmx ctx)
|
||||
(csrf ctx)
|
||||
|> render "Reset Password" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/reset-password
|
||||
let doResetPassword : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<ResetPasswordForm> ()
|
||||
let errors = [
|
||||
if form.Id = "" then "Request invalid; please return to the link in your e-mail and try again"
|
||||
if form.Token = "" then "Request invalid; please return to the link in your e-mail and try again"
|
||||
if form.Password.Length < 8 then "Password too short"
|
||||
]
|
||||
if List.isEmpty errors then
|
||||
match! Data.trySecurityByToken form.Token with
|
||||
| Some security when security.Id = CitizenId.ofString form.Id ->
|
||||
match! Data.findById security.Id with
|
||||
| Some citizen ->
|
||||
do! Data.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None }
|
||||
do! Data.save { citizen with PasswordHash = Auth.Passwords.hash citizen form.Password }
|
||||
do! addSuccess "Password reset successfully; you may log on with your new credentials" ctx
|
||||
return! redirectToGet "/citizen/log-on" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| Some _
|
||||
| None -> return! Error.notFound next ctx
|
||||
else
|
||||
do! addErrors errors ctx
|
||||
return! Views.resetPassword form (isHtmx ctx) (csrf ctx) |> render "Reset Password" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/save-account
|
||||
let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! theForm = ctx.BindFormAsync<AccountProfileForm> ()
|
||||
let form = { theForm with Contacts = theForm.Contacts |> Array.filter (box >> isNull >> not) }
|
||||
let errors = [
|
||||
if form.FirstName = "" then "First Name is required"
|
||||
if form.LastName = "" then "Last Name is required"
|
||||
if form.NewPassword <> form.NewPassword then "New passwords do not match"
|
||||
if form.Contacts |> Array.exists (fun c -> c.ContactType = "") then "All Contact Types are required"
|
||||
if form.Contacts |> Array.exists (fun c -> c.Value = "") then "All Contacts are required"
|
||||
]
|
||||
if List.isEmpty errors then
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some citizen ->
|
||||
let password =
|
||||
if form.NewPassword = "" then citizen.PasswordHash
|
||||
else Auth.Passwords.hash citizen form.NewPassword
|
||||
do! Data.save
|
||||
{ citizen with
|
||||
FirstName = form.FirstName
|
||||
LastName = form.LastName
|
||||
DisplayName = noneIfEmpty form.DisplayName
|
||||
PasswordHash = password
|
||||
OtherContacts = form.Contacts
|
||||
|> Array.map (fun c ->
|
||||
{ OtherContact.Name = noneIfEmpty c.Name
|
||||
ContactType = ContactType.parse c.ContactType
|
||||
Value = c.Value
|
||||
IsPublic = c.IsPublic
|
||||
})
|
||||
|> List.ofArray
|
||||
}
|
||||
let extraMsg = if form.NewPassword = "" then "" else " and password changed"
|
||||
do! addSuccess $"Account profile updated{extraMsg} successfully" ctx
|
||||
return! redirectToGet "/citizen/account" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
else
|
||||
do! addErrors errors ctx
|
||||
return! Views.account form (isHtmx ctx) (csrf ctx) |> render "Account Profile" next ctx
|
||||
}
|
||||
|
||||
// GET: /citizen/so-long
|
||||
let soLong : HttpHandler = requireUser >=> fun next ctx ->
|
||||
Views.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
// GET: /citizen/legacy
|
||||
let legacy : HttpHandler = Auth.requireAdmin >=> fun next ctx -> task {
|
||||
let! currentUsers = Data.current ()
|
||||
let! legacyUsers = Data.legacy ()
|
||||
return! Views.legacy currentUsers legacyUsers (csrf ctx) |> render "Migrate Legacy Account" next ctx
|
||||
}
|
||||
|
||||
// POST: /citizen/legacy/migrate
|
||||
let migrateLegacy : HttpHandler = Auth.requireAdmin >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! form = ctx.BindFormAsync<LegacyMigrationForm> ()
|
||||
let currentId = CitizenId.ofString form.Id
|
||||
let legacyId = CitizenId.ofString form.LegacyId
|
||||
match! Data.migrateLegacy currentId legacyId with
|
||||
| Ok _ -> do! addSuccess "Migration successful" ctx
|
||||
| Error err -> do! addError err ctx
|
||||
return! redirectToGet "/citizen/legacy" next ctx
|
||||
}
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// All endpoints for this feature
|
||||
let endpoints =
|
||||
subRoute "/citizen" [
|
||||
GET_HEAD [
|
||||
route "/account" account
|
||||
routef "/cancel-reset/%s" cancelReset
|
||||
routef "/confirm/%s" confirm
|
||||
route "/dashboard" dashboard
|
||||
routef "/deny/%s" deny
|
||||
route "/forgot-password" forgotPassword
|
||||
route "/log-off" logOff
|
||||
route "/log-on" logOn
|
||||
route "/register" register
|
||||
routef "/reset-password/%s" resetPassword
|
||||
route "/so-long" soLong
|
||||
route "/legacy" legacy
|
||||
]
|
||||
POST [
|
||||
route "/delete" delete
|
||||
route "/forgot-password" doForgotPassword
|
||||
route "/log-on" doLogOn
|
||||
route "/register" doRegistration
|
||||
route "/reset-password" doResetPassword
|
||||
route "/save-account" saveAccount
|
||||
route "/legacy/migrate" migrateLegacy
|
||||
]
|
||||
]
|
||||
19
src/JobsJobsJobs/Citizens/JobsJobsJobs.Citizens.fsproj
Normal file
19
src/JobsJobsJobs/Citizens/JobsJobsJobs.Citizens.fsproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Views.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
|
||||
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
446
src/JobsJobsJobs/Citizens/Views.fs
Normal file
446
src/JobsJobsJobs/Citizens/Views.fs
Normal file
@@ -0,0 +1,446 @@
|
||||
/// Views for URLs beginning with /citizen
|
||||
module JobsJobsJobs.Citizens.Views
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open JobsJobsJobs.Citizens.Domain
|
||||
open JobsJobsJobs.Common.Views
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// The form to add or edit a means of contact
|
||||
let contactEdit (contacts : OtherContactForm array) =
|
||||
let mapToInputs (idx : int) (contact : OtherContactForm) =
|
||||
div [ _id $"contactRow{idx}"; _class "row pb-3" ] [
|
||||
div [ _class "col-2 col-md-1" ] [
|
||||
button [ _type "button"; _class "btn btn-sm btn-outline-danger rounded-pill mt-3"; _title "Delete"
|
||||
_onclick $"jjj.citizen.removeContact({idx})" ] [ txt " − " ]
|
||||
]
|
||||
div [ _class "col-10 col-md-4 col-xl-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
select [ _id $"contactType{idx}"; _name $"Contacts[{idx}].ContactType"; _class "form-control"
|
||||
_value contact.ContactType; _placeholder "Type"; _required ] [
|
||||
let optionFor value label =
|
||||
let typ = ContactType.toString value
|
||||
option [ _value typ; if contact.ContactType = typ then _selected ] [ txt label ]
|
||||
optionFor Website "Website"
|
||||
optionFor Email "E-mail Address"
|
||||
optionFor Phone "Phone Number"
|
||||
]
|
||||
label [ _class "jjj-required"; _for $"contactType{idx}" ] [ txt "Type" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"contactName{idx}"; _name $"Contacts[{idx}].Name"; _class "form-control"
|
||||
_maxlength "1000"; _value contact.Name; _placeholder "Name" ]
|
||||
label [ _class "jjj-label"; _for $"contactName{idx}" ] [ txt "Name" ]
|
||||
]
|
||||
if idx < 1 then
|
||||
div [ _class "form-text" ] [ txt "Optional; will link sites and e-mail, qualify phone numbers" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-7 offset-md-1 col-xl-4 offset-xl-0" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"contactValue{idx}"; _name $"Contacts[{idx}].Value"
|
||||
_class "form-control"; _maxlength "1000"; _value contact.Value; _placeholder "Contact"
|
||||
_required ]
|
||||
label [ _class "jjj-required"; _for "contactValue{idx}" ] [ txt "Contact" ]
|
||||
]
|
||||
if idx < 1 then div [ _class "form-text"] [ txt "The URL, e-mail address, or phone number" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-3 offset-md-1 col-xl-1 offset-xl-0" ] [
|
||||
div [ _class "form-check mt-3" ] [
|
||||
input [ _type "checkbox"; _id $"contactIsPublic{idx}"; _name $"Contacts[{idx}].IsPublic";
|
||||
_class "form-check-input"; _value "true"; if contact.IsPublic then _checked ]
|
||||
label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ txt "Public" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
template [ _id "newContact" ] [
|
||||
mapToInputs -1 { ContactType = "Website"; Name = ""; Value = ""; IsPublic = false }
|
||||
]
|
||||
:: (contacts |> Array.mapi mapToInputs |> List.ofArray)
|
||||
|
||||
/// The account edit page
|
||||
let account (m : AccountProfileForm) isHtmx csrf =
|
||||
pageWithTitle "Account Profile" [
|
||||
p [] [
|
||||
txt "This information is visible to all fellow logged-on citizens. For publicly-visible employment "
|
||||
txt "profiles and job listings, the “Display Name” fields and any public contacts will be "
|
||||
txt "displayed."
|
||||
]
|
||||
form [ _class "row g-3"; _method "POST"; _action "/citizen/save-account" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.DisplayName) m.DisplayName "Display Name" false
|
||||
div [ _class "form-text" ] [ em [] [ txt "Optional; overrides first/last for display" ] ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPassword) "" "New Password" false
|
||||
div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPasswordConfirm) "" "Confirm New Password"
|
||||
false
|
||||
div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [ _class "pb-2" ] [
|
||||
txt "Ways to Be Contacted "
|
||||
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
|
||||
_onclick "jjj.citizen.addContact()" ] [ txt "Add a Contact Method" ]
|
||||
]
|
||||
]
|
||||
yield! contactEdit m.Contacts
|
||||
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
|
||||
]
|
||||
hr []
|
||||
p [ _class "text-muted fst-italic" ] [
|
||||
txt "(If you want to delete your profile, or your entire account, "
|
||||
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; txt ".)"
|
||||
]
|
||||
jsOnLoad $"
|
||||
jjj.citizen.nextIndex = {m.Contacts.Length}
|
||||
jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)" isHtmx
|
||||
]
|
||||
|
||||
|
||||
/// The account confirmation page
|
||||
let confirmAccount isConfirmed =
|
||||
pageWithTitle "Account Confirmation" [
|
||||
p [] [
|
||||
if isConfirmed then
|
||||
txt "Your account was confirmed successfully! You may "
|
||||
a [ _href "/citizen/log-on" ] [ rawText "log on here" ]; txt "."
|
||||
else
|
||||
txt "The confirmation token did not match any pending accounts. Confirmation tokens are only valid for "
|
||||
txt "3 days; if the token expired, you will need to re-register, which "
|
||||
a [ _href "/citizen/register" ] [ txt "you can do here" ]; txt "."
|
||||
]
|
||||
]
|
||||
|
||||
/// The citizen's dashboard page
|
||||
let dashboard (citizen : Citizen) (profile : Profile option) profileCount tz =
|
||||
article [ _class "container" ] [
|
||||
h3 [ _class "pb-4" ] [ str $"ITM, {citizen.FirstName}!" ]
|
||||
div [ _class "row row-cols-1 row-cols-md-2" ] [
|
||||
div [ _class "col" ] [
|
||||
div [ _class "card h-100" ] [
|
||||
h5 [ _class "card-header" ] [ txt "Your Profile" ]
|
||||
div [ _class "card-body" ] [
|
||||
match profile with
|
||||
| Some prfl ->
|
||||
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
|
||||
str $"Last updated {fullDateTime prfl.LastUpdatedOn tz}"
|
||||
]
|
||||
p [ _class "card-text" ] [
|
||||
txt $"Your profile currently lists {List.length prfl.Skills} skill"
|
||||
txt (if List.length prfl.Skills <> 1 then "s" else ""); txt "."
|
||||
if prfl.IsSeekingEmployment then
|
||||
br []; br []
|
||||
txt "Your profile indicates that you are seeking employment. Once you find it, "
|
||||
a [ _href "/success-story/add" ] [ txt "tell your fellow citizens about it!" ]
|
||||
]
|
||||
| None ->
|
||||
p [ _class "card-text" ] [
|
||||
txt "You do not have an employment profile established; click below (or “Edit "
|
||||
txt "Profile” in the menu) to get started!"
|
||||
]
|
||||
]
|
||||
div [ _class "card-footer" ] [
|
||||
match profile with
|
||||
| Some _ ->
|
||||
a [ _href $"/profile/{CitizenId.toString citizen.Id}/view"
|
||||
_class "btn btn-outline-secondary" ] [ txt "View Profile" ]; txt " "
|
||||
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ txt "Edit Profile" ]
|
||||
| None ->
|
||||
a [ _href "/profile/edit"; _class "btn btn-primary" ] [ txt "Create Profile" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col" ] [
|
||||
div [ _class "card h-100" ] [
|
||||
h5 [ _class "card-header" ] [ txt "Other Citizens" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
|
||||
txt (if profileCount = 0L then "No" else $"{profileCount} Total")
|
||||
txt " Employment Profile"; txt (if profileCount <> 1 then "s" else "")
|
||||
]
|
||||
p [ _class "card-text" ] [
|
||||
if profileCount = 1 && Option.isSome profile then
|
||||
"It looks like, for now, it’s just you…"
|
||||
else if profileCount > 0 then "Take a look around and see if you can help them find work!"
|
||||
else "You can click below, but you will not find anything…"
|
||||
|> txt
|
||||
]
|
||||
]
|
||||
div [ _class "card-footer" ] [
|
||||
a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ txt "Search Profiles" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
emptyP
|
||||
p [] [
|
||||
txt "To see how this application works, check out “How It Works” in the sidebar (last updated "
|
||||
txt "August 29<sup>th</sup>, 2021)."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The account deletion success page
|
||||
let deleted =
|
||||
pageWithTitle "Account Deletion Success" [
|
||||
emptyP; p [] [ txt "Your account has been successfully deleted." ]
|
||||
emptyP; p [] [ txt "Thank you for participating, and thank you for your courage. #GitmoNation" ]
|
||||
]
|
||||
|
||||
|
||||
/// The profile or account deletion page
|
||||
let deletionOptions csrf =
|
||||
pageWithTitle "Account Deletion Options" [
|
||||
h4 [ _class "pb-3" ] [ txt "Option 1 – Delete Your Profile" ]
|
||||
p [] [
|
||||
txt "Utilizing this option will remove your current employment profile and skills. This will preserve any "
|
||||
txt "job listings you may have posted, or any success stories you may have written, and preserves this "
|
||||
txt "this application’s knowledge of you. This is what you want to use if you want to clear out your "
|
||||
txt "profile and start again (and remove the current one from others’ view)."
|
||||
]
|
||||
form [ _class "text-center"; _method "POST"; _action "/profile/delete" ] [
|
||||
antiForgery csrf
|
||||
button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Profile" ]
|
||||
]
|
||||
hr []
|
||||
h4 [ _class "pb-3" ] [ txt "Option 2 – Delete Your Account" ]
|
||||
p [] [
|
||||
txt "This option will make it like you never visited this site. It will delete your profile, skills, job "
|
||||
txt "listings, success stories, and account. This is what you want to use if you want to disappear from "
|
||||
txt "this application."
|
||||
]
|
||||
form [ _class "text-center"; _method "POST"; _action "/citizen/delete" ] [
|
||||
antiForgery csrf
|
||||
button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Entire Account" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The account denial page
|
||||
let denyAccount wasDeleted =
|
||||
pageWithTitle "Account Deletion" [
|
||||
p [] [
|
||||
if wasDeleted then txt "The account was deleted successfully; sorry for the trouble."
|
||||
else
|
||||
txt "The confirmation token did not match any pending accounts; if this was an inadvertently created "
|
||||
txt "account, it has likely already been deleted."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The forgot / reset password page
|
||||
let forgotPassword csrf =
|
||||
let m = { Email = "" }
|
||||
pageWithTitle "Forgot Password" [
|
||||
p [] [
|
||||
txt "Enter your e-mail address below; if it matches the e-mail address of an account, we will send a "
|
||||
txt "password reset link."
|
||||
]
|
||||
form [ _class "row g-3 pb-3"; _method "POST"; _action "/citizen/forgot-password" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-12 col-md-6 offset-md-3" ] [
|
||||
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "send-lock-outline" "Send Reset Link" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The page displayed after a forgotten / reset request has been processed
|
||||
let forgotPasswordSent (m : ForgotPasswordForm) =
|
||||
pageWithTitle "Reset Request Processed" [
|
||||
p [] [
|
||||
txt $"The reset link request has been processed. If the e-mail address {m.Email} matched an account, "
|
||||
txt "further instructions were sent to that address."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The log on page
|
||||
let logOn (m : LogOnForm) csrf =
|
||||
pageWithTitle "Log On" [
|
||||
match m.ErrorMessage with
|
||||
| Some msg ->
|
||||
p [ _class "pb-3 text-center" ] [
|
||||
span [ _class "text-danger" ] [ txt msg ]; br []
|
||||
if msg.IndexOf("ocked") > -1 then
|
||||
txt "If this is a new account, it must be confirmed before it can be used; otherwise, you need to "
|
||||
a [ _href "/citizen/forgot-password" ] [ txt "request an unlock code" ]
|
||||
txt " before you may log on."
|
||||
]
|
||||
| None -> ()
|
||||
form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [
|
||||
antiForgery csrf
|
||||
match m.ReturnTo with
|
||||
| Some returnTo -> input [ _type "hidden"; _name (nameof m.ReturnTo); _value returnTo ]
|
||||
| None -> ()
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "password" ] (nameof m.Password) "" "Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "login" "Log On" ]
|
||||
]
|
||||
p [ _class "text-center" ] [
|
||||
txt "Need an account? "; a [ _href "/citizen/register" ] [ txt "Register for one!" ]
|
||||
]
|
||||
p [ _class "text-center" ] [
|
||||
txt "Forgot your password? "; a [ _href "/citizen/forgot-password" ] [ txt "Request a reset." ]
|
||||
]
|
||||
]
|
||||
|
||||
/// The registration page
|
||||
let register q1 q2 (m : RegisterForm) isHtmx csrf =
|
||||
pageWithTitle "Register" [
|
||||
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false
|
||||
div [ _class "form-text fst-italic" ] [ txt "Optional; overrides first/last for display" ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.Password) "" "Password" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
p [ _class "mb-0 text-muted fst-italic" ] [
|
||||
txt "Before your account request is through, you must answer these questions two…"
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question1Answer) m.Question1Answer q1 true
|
||||
input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ]
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer q2 true
|
||||
input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ]
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
|
||||
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
|
||||
]
|
||||
]
|
||||
|
||||
/// The confirmation page for user registration
|
||||
let registered =
|
||||
pageWithTitle "Registration Successful" [
|
||||
p [] [
|
||||
txt "You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation "
|
||||
txt "link; it will be valid for the next 72 hours (3 days). Once you confirm your account, you will be "
|
||||
txt "able to log on using the e-mail address and password you provided."
|
||||
]
|
||||
p [] [
|
||||
txt "If the account is not confirmed within the 72-hour window, it will be deleted, and you will need to "
|
||||
txt "register again."
|
||||
]
|
||||
p [] [
|
||||
txt "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance."
|
||||
]
|
||||
]
|
||||
|
||||
/// The confirmation page for canceling a reset request
|
||||
let resetCanceled wasCanceled =
|
||||
let pgTitle = if wasCanceled then "Password Reset Request Canceled" else "Reset Request Not Found"
|
||||
pageWithTitle pgTitle [
|
||||
p [] [
|
||||
if wasCanceled then txt "Your password reset request has been canceled."
|
||||
else txt "There was no active password reset request found; it may have already expired."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The password reset page
|
||||
let resetPassword (m : ResetPasswordForm) isHtmx csrf =
|
||||
pageWithTitle "Reset Password" [
|
||||
p [] [ txt "Enter your new password in the fields below" ]
|
||||
form [ _class "row g-3"; _method "POST"; _action "/citizen/reset-password" ] [
|
||||
antiForgery csrf
|
||||
input [ _type "hidden"; _name (nameof m.Id); _value m.Id ]
|
||||
input [ _type "hidden"; _name (nameof m.Token); _value m.Token ]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 offset-xl-2" ] [
|
||||
textBox [ _type "password"; _minlength "8"; _autofocus ] (nameof m.Password) "" "New Password" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4" ] [
|
||||
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm New Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "lock-reset" "Reset Password" ]
|
||||
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
|
||||
]
|
||||
]
|
||||
|
||||
// ~~~ LEGACY MIGRATION ~~~ //
|
||||
|
||||
let legacy (current : Citizen list) (legacy : Citizen list) csrf =
|
||||
form [ _class "container"; _hxPost "/citizen/legacy/migrate" ] [
|
||||
antiForgery csrf
|
||||
let canProcess = not (List.isEmpty current)
|
||||
div [ _class "row" ] [
|
||||
if canProcess then
|
||||
div [ _class "col-12 col-lg-6 col-xxl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
select [ _id "current"; _name "Id"; _class "form-control" ] [
|
||||
option [ _value "" ] [ txt "– Select –" ]
|
||||
yield!
|
||||
current
|
||||
|> List.sortBy Citizen.name
|
||||
|> List.map (fun it ->
|
||||
option [ _value (CitizenId.toString it.Id) ] [
|
||||
str (Citizen.name it); txt " ("; str it.Email; txt ")"
|
||||
])
|
||||
]
|
||||
label [ _for "current" ] [ txt "Current User" ]
|
||||
]
|
||||
]
|
||||
else p [] [ txt "There are no current accounts to which legacy accounts can be migrated" ]
|
||||
div [ _class "col-12 col-lg-6 offset-xxl-2"] [
|
||||
table [ _class "table table-sm table-hover" ] [
|
||||
thead [] [
|
||||
tr [] [
|
||||
th [ _scope "col" ] [ txt "Select" ]
|
||||
th [ _scope "col" ] [ txt "NAS Profile" ]
|
||||
]
|
||||
]
|
||||
legacy |> List.map (fun it ->
|
||||
let theId = CitizenId.toString it.Id
|
||||
tr [] [
|
||||
td [] [
|
||||
if canProcess then
|
||||
input [ _type "radio"; _id $"legacy_{theId}"; _name "LegacyId"; _value theId ]
|
||||
else txt " "
|
||||
]
|
||||
td [] [ label [ _for $"legacy_{theId}" ] [ str it.Email ] ]
|
||||
])
|
||||
|> tbody []
|
||||
]
|
||||
]
|
||||
]
|
||||
submitButton "content-save-outline" "Migrate Account"
|
||||
]
|
||||
|> List.singleton
|
||||
|> pageWithTitle "Migrate Legacy Account"
|
||||
Reference in New Issue
Block a user