module Aftok.Signup where import Prelude import Control.Monad.Trans.Class (lift) import Data.Bifunctor (bimap) import Data.Either (Either(..), note, fromRight) import Data.Foldable (any, intercalate) import Data.Maybe (Maybe(..), fromMaybe) import Data.Map as M import Data.String.Regex (regex, split) import Data.String.Regex.Flags (global) import Data.Tuple (Tuple(..)) import Data.Traversable (traverse_) import Data.Validation.Semigroup (V(..), toEither, andThen, invalid) import Effect.Aff (Aff) import Effect.Class (liftEffect) import Partial.Unsafe (unsafePartial) import Affjax.StatusCode (StatusCode) import Halogen as H import Halogen.HTML.Core (ClassName(..)) import Halogen.HTML as HH import Halogen.HTML.Events as E import Web.Event.Event as WE import Halogen.HTML.Properties as P import URI.Extra.QueryPairs (QueryPairs(..), keyToString, valueToString) import Aftok.Types (System) import Aftok.Api.Account as Acc import Aftok.Api.Account (SignupRequest, SignupResponse, signupRequest) import Aftok.Api.Recaptcha (getRecaptchaResponse, recaptchaRender) import Aftok.Api.Types (CommsType(..)) import Aftok.HTML.Forms (commsSwitch, commsField) import Aftok.Navigation (parseURIQuery) data SignupError = UsernameRequired | UsernameTaken | PasswordRequired | ConfirmRequired | PasswordMismatch | EmailRequired | ZAddrRequired | ZAddrInvalid | CodesParseFailure | CaptchaError | APIError { status :: Maybe StatusCode, message :: String } data SignupField = UsernameField | PasswordField | ConfirmField | EmailField | ZAddrField | InvCodesField | CaptchaField | ErrField derive instance signupFieldEq :: Eq SignupField derive instance signupFieldOrd :: Ord SignupField type SignupState = { username :: Maybe String , password :: Maybe String , passwordConfirm :: Maybe String , channel :: CommsType , email :: Maybe String , zaddr :: Maybe String , invitationCodes :: Array String , signupErrors :: M.Map SignupField SignupError } data SignupAction = Initialize | SetUsername String | SetPassword String | ConfirmPassword String | SetRecoveryType CommsType | SetRecoveryEmail String | SetRecoveryZAddr String | SetInvitationCodes String | Signup WE.Event data SignupResult = SignupComplete String | SigninNav type Slot id = forall query. H.Slot query SignupResult id type Capability m = { checkUsername :: String -> m Acc.UsernameCheckResponse , checkZAddr :: String -> m Acc.ZAddrCheckResponse , signup :: SignupRequest -> m SignupResponse , getRecaptchaResponse :: Maybe String -> m (Maybe String) , recaptchaRender :: String -> String -> m Unit } type Config = { recaptchaKey :: String } component :: forall query input m. Monad m => System m -> Capability m -> Config -> H.Component HH.HTML query input SignupResult m component system caps conf = H.mkComponent { initialState , render , eval: H.mkEval $ H.defaultEval { handleAction = eval, initialize = Just Initialize } } where initialState :: input -> SignupState initialState _ = { username: Nothing , password: Nothing , passwordConfirm: Nothing , channel: EmailComms , email: Nothing , zaddr: Nothing , invitationCodes: [] , signupErrors: M.empty } render :: forall slots. SignupState -> H.ComponentHTML SignupAction slots m render st = HH.section [ P.classes (ClassName <$> [ "section-border", "border-primary" ]) ] [ HH.div [ P.classes (ClassName <$> [ "container", "d-flex", "flex-column" ]) ] [ HH.div [ P.classes (ClassName <$> [ "align-items-center", "pt-6" ]) ] [ HH.h1 [ P.classes (ClassName <$> [ "mb-0", "font-weight-bold", "text-center" ]) ] [ HH.text "Sign up" ] , HH.p [ P.classes (ClassName <$> [ "text-center", "text-muted", "col-md-5", "mx-auto" ]) ] [ HH.text "You can use either an email address or shielded zcash address for account recovery." ] ] , HH.div [ P.classes (ClassName <$> [ "row", "align-items-center", "justify-content-center", "no-gutters" ]) ] [ HH.div [ P.classes (ClassName <$> [ "col-12", "col-lg-4", "py-8", "py-md-0" ]) ] [ HH.form [ P.classes (ClassName <$> [ "mb-6" ]) , E.onSubmit (Just <<< Signup) ] [ HH.div [ P.classes (ClassName <$> [ "form-group" ]) ] $ [ HH.label [ P.for "username" ] [ HH.text "Username" ] , HH.input [ P.type_ P.InputText , P.classes (ClassName <$> [ "form-control" ]) , P.id_ "username" , P.placeholder "Choose a handle (username)" , P.required true , P.autofocus true , P.value (fromMaybe "" st.username) , E.onValueInput (Just <<< SetUsername) ] ] <> signupErrors st UsernameField , HH.div [ P.classes (ClassName <$> [ "form-group" ]) ] $ [ HH.label [ P.for "password" ] [ HH.text "Password" ] , HH.input [ P.type_ P.InputPassword , P.classes (ClassName <$> [ "form-control" ]) , P.id_ "password" , P.placeholder "Enter a unique password" , P.required true , P.value (fromMaybe "" st.password) , E.onValueInput (Just <<< SetPassword) ] ] <> signupErrors st PasswordField <> [ HH.input [ P.type_ P.InputPassword , P.classes (ClassName <$> [ "form-control" ]) , P.id_ "passwordConfirm" , P.placeholder "Enter a unique password" , P.required true , P.value (fromMaybe "" st.passwordConfirm) , E.onValueInput (Just <<< ConfirmPassword) ] ] <> signupErrors st ConfirmField , commsSwitch SetRecoveryType st.channel , commsField SetRecoveryEmail SetRecoveryZAddr st $ case _ of EmailComms -> signupErrors st EmailField ZcashComms -> signupErrors st ZAddrField , HH.div [ P.classes (ClassName <$> [ "form-group" ]) ] $ [ HH.label [ P.for "invitationCodes" ] [ HH.text "Invitation Codes" ] , HH.input [ P.type_ P.InputText , P.classes (ClassName <$> [ "form-control" ]) , P.id_ "invitationCodes" , P.placeholder "abcdefgh, ..." , P.value (intercalate ", " st.invitationCodes) , E.onValueInput (Just <<< SetInvitationCodes) ] ] <> signupErrors st InvCodesField , HH.div [ P.classes (ClassName <$> [ "form-group", "mb-3" ]) ] [ HH.div [ P.id_ "grecaptcha" ] [] ] , HH.button [ P.classes (ClassName <$> [ "btn", "btn-block", "btn-primary" ]) ] [ HH.text "Sign up" ] ] , HH.p [ P.classes (ClassName <$> [ "mb-0", "font-size-sm", "text-center", "text-muted" ]) ] [ HH.text "Already have an account? " , HH.a [ P.href "#login" ] [ HH.text "Sign in" ] ] ] ] ] ] setZAddr addr = do zres <- lift $ caps.checkZAddr addr H.modify_ (_ { zaddr = Just addr }) case zres of Acc.ZAddrCheckValid -> H.modify_ (\st -> st { signupErrors = M.delete ZAddrField st.signupErrors, channel = ZcashComms }) Acc.ZAddrCheckInvalid -> H.modify_ (\st -> st { signupErrors = M.insert ZAddrField ZAddrInvalid st.signupErrors }) eval :: SignupAction -> H.HalogenM SignupState SignupAction () SignupResult m Unit eval = case _ of Initialize -> do lift $ system.log "Initializing signup page..." loc <- lift system.href case parseURIQuery loc of (Right (Just (QueryPairs q))) -> do let pairsMap = M.fromFoldable $ (bimap keyToString (map valueToString)) <$> q traverse_ (\c -> H.modify_ (_ { invitationCodes = [c] })) (join $ M.lookup "invcode" pairsMap) traverse_ setZAddr (join $ M.lookup "zaddr" pairsMap) (Right Nothing) -> pure unit (Left err) -> lift $ system.error ("Parsing failed for location string " <> loc) lift $ caps.recaptchaRender conf.recaptchaKey "grecaptcha" SetUsername user -> do ures <- lift $ caps.checkUsername user H.modify_ (_ { username = Just user }) case ures of Acc.UsernameCheckOK -> H.modify_ (\st -> st { signupErrors = M.delete UsernameField st.signupErrors }) Acc.UsernameCheckTaken -> H.modify_ (\st -> st { signupErrors = M.insert UsernameField UsernameTaken st.signupErrors }) SetPassword pass -> do H.modify_ (_ { password = Just pass }) confirm <- H.gets (_.passwordConfirm) if (any (notEq pass) confirm) then (H.modify_ (\st -> st { signupErrors = M.insert ConfirmField PasswordMismatch st.signupErrors })) else (H.modify_ (\st -> st { signupErrors = M.delete ConfirmField st.signupErrors })) ConfirmPassword confirm -> do H.modify_ (_ { passwordConfirm = Just confirm }) pass <- H.gets (_.password) if (any (notEq confirm) pass) then (H.modify_ (\st -> st { signupErrors = M.insert ConfirmField PasswordMismatch st.signupErrors })) else (H.modify_ (\st -> st { signupErrors = M.delete ConfirmField st.signupErrors })) SetRecoveryType t -> H.modify_ (_ { channel = t }) SetRecoveryEmail email -> H.modify_ (_ { email = Just email }) SetRecoveryZAddr addr -> when (addr /= "") (setZAddr addr) SetInvitationCodes codeStr -> do let r = unsafePartial (fromRight $ regex "\\s*,\\s*" global) codes = split r codeStr H.modify_ (_ { invitationCodes = codes }) Signup ev -> do lift $ system.preventDefault ev recType <- H.gets (_.channel) usernameV <- V <<< note [ UsernameRequired ] <$> H.gets (_.username) pwdFormV <- V <<< note [ PasswordRequired ] <$> H.gets (_.password) pwdConfV <- V <<< note [ ConfirmRequired ] <$> H.gets (_.passwordConfirm) channel <- H.gets (_.channel) recoveryV <- case channel of EmailComms -> V <<< note [ EmailRequired ] <<< map Acc.RecoverByEmail <$> H.gets (_.email) ZcashComms -> V <<< note [ ZAddrRequired ] <<< map Acc.RecoverByZAddr <$> H.gets (_.zaddr) recapV <- lift $ V <<< note [ CaptchaError ] <$> caps.getRecaptchaResponse Nothing invcodes <- H.gets (_.invitationCodes) --lift $ system.log "Sending signup request..." let reqV :: V (Array SignupError) Acc.SignupRequest reqV = signupRequest <$> usernameV <*> ( (eq <$> pwdFormV <*> pwdConfV) `andThen` (if _ then pwdFormV else invalid [ PasswordMismatch ]) ) <*> recoveryV <*> recapV <*> pure invcodes case toEither reqV of Left errors -> do let errMap = M.fromFoldable $ map (\e -> Tuple (errField e) e) errors --lift $ system.log "Got signup HTTP error." H.modify_ (_ { signupErrors = errMap }) Right req -> do response <- lift (caps.signup req) --lift <<< system.log $ "Got signup response " <> show response case response of Acc.SignupOK -> H.raise (SignupComplete $ req.username) Acc.CaptchaInvalid -> H.modify_ (_ { signupErrors = M.singleton CaptchaField CaptchaError }) Acc.ZAddrInvalid -> H.modify_ (_ { signupErrors = M.singleton ZAddrField ZAddrInvalid }) Acc.UsernameTaken -> H.modify_ (_ { signupErrors = M.singleton UsernameField UsernameTaken }) Acc.ServiceError c m -> H.modify_ (_ { signupErrors = M.singleton ErrField (APIError { status: c, message: m }) }) errField :: SignupError -> SignupField errField = case _ of UsernameRequired -> UsernameField UsernameTaken -> UsernameField PasswordRequired -> PasswordField ConfirmRequired -> ConfirmField PasswordMismatch -> ConfirmField EmailRequired -> EmailField ZAddrRequired -> ZAddrField ZAddrInvalid -> ZAddrField CodesParseFailure -> InvCodesField CaptchaError -> CaptchaField APIError _ -> ErrField signupErrors :: forall i a. SignupState -> SignupField -> Array (HH.HTML i a) signupErrors st field = case M.lookup field st.signupErrors of (Just UsernameRequired) -> err "Username is required" (Just UsernameTaken) -> err "Username is already taken" (Just PasswordRequired) -> err "Password is required" (Just ConfirmRequired) -> err "Confirm your password" (Just PasswordMismatch) -> err "Passwords do not match" (Just EmailRequired) -> err "Email address is required" (Just ZAddrRequired) -> err "Zcash address is required" (Just ZAddrInvalid) -> err "Not a valid Zcash address" (Just CaptchaError) -> err "Captcha failed; please try again" _ -> [] where err str = [ HH.div_ [ HH.span [ P.classes (ClassName <$> [ "badge", "badge-danger-soft" ]) ] [ HH.text str ] ] ] apiCapability :: Capability Aff apiCapability = { checkUsername: Acc.checkUsername , checkZAddr: Acc.checkZAddr , signup: Acc.signup , getRecaptchaResponse: liftEffect <<< getRecaptchaResponse , recaptchaRender: \siteKey elemId -> liftEffect $ recaptchaRender siteKey elemId } mockCapability :: Capability Aff mockCapability = { checkUsername: \_ -> pure Acc.UsernameCheckOK , checkZAddr: \_ -> pure Acc.ZAddrCheckValid , signup: \_ -> pure Acc.SignupOK , getRecaptchaResponse: liftEffect <<< getRecaptchaResponse , recaptchaRender: \siteKey elemId -> liftEffect $ recaptchaRender siteKey elemId }