Starting with mail functionality

This commit is contained in:
Eike Kettner 2020-01-05 23:23:28 +01:00
parent 2e3454c7a1
commit f235f3a030
14 changed files with 853 additions and 56 deletions

View File

@ -23,6 +23,7 @@ trait BackendApp[F[_]] {
def node: ONode[F]
def job: OJob[F]
def item: OItem[F]
def mail: OMail[F]
}
object BackendApp {
@ -45,6 +46,7 @@ object BackendApp {
nodeImpl <- ONode(store)
jobImpl <- OJob(store, httpClientEc)
itemImpl <- OItem(store)
mailImpl <- OMail(store)
} yield new BackendApp[F] {
val login: Login[F] = loginImpl
val signup: OSignup[F] = signupImpl
@ -57,6 +59,7 @@ object BackendApp {
val node = nodeImpl
val job = jobImpl
val item = itemImpl
val mail = mailImpl
}
def apply[F[_]: ConcurrentEffect: ContextShift](

View File

@ -0,0 +1,43 @@
package docspell.backend.ops
import cats.effect._
import cats.implicits._
import cats.data.OptionT
import docspell.common._
import docspell.store._
import docspell.store.records.RUserEmail
trait OMail[F[_]] {
def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]]
def createSettings(data: F[RUserEmail]): F[AddResult]
def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int]
}
object OMail {
def apply[F[_]: Effect](store: Store[F]): Resource[F, OMail[F]] =
Resource.pure(new OMail[F] {
def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] =
store.transact(RUserEmail.findByAccount(accId, nameQ))
def createSettings(data: F[RUserEmail]): F[AddResult] =
for {
ru <- data
ins = RUserEmail.insert(ru)
exists = RUserEmail.exists(ru.uid, ru.name)
ar <- store.add(ins, exists)
} yield ar
def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] = {
val op = for {
um <- OptionT(RUserEmail.getByName(accId, name))
n <- OptionT.liftF(RUserEmail.update(um.id, data))
} yield n
store.transact(op.value).map(_.getOrElse(0))
}
})
}

View File

@ -1222,7 +1222,10 @@ paths:
sent e-mails.
Multiple e-mail settings can be specified, they are
distinguished by their `name`.
distinguished by their `name`. The query `q` parameter does a
simple substring search in the connection name.
parameters:
- $ref: "#/components/parameters/q"
responses:
200:
description: Ok
@ -1352,12 +1355,9 @@ components:
required:
- name
- smtpHost
- smtpPort
- smtpUser
- smtpPassword
- from
- sslType
- certificateCheck
- ignoreCertificates
properties:
name:
type: string
@ -1374,9 +1374,11 @@ components:
format: password
from:
type: string
replyTo:
type: string
sslType:
type: string
certificateCheck:
ignoreCertificates:
type: boolean
CheckFileResult:
description: |

View File

@ -57,19 +57,20 @@ object RestServer {
token: AuthToken
): HttpRoutes[F] =
Router(
"auth" -> LoginRoutes.session(restApp.backend.login, cfg),
"tag" -> TagRoutes(restApp.backend, token),
"equipment" -> EquipmentRoutes(restApp.backend, token),
"organization" -> OrganizationRoutes(restApp.backend, token),
"person" -> PersonRoutes(restApp.backend, token),
"source" -> SourceRoutes(restApp.backend, token),
"user" -> UserRoutes(restApp.backend, token),
"collective" -> CollectiveRoutes(restApp.backend, token),
"queue" -> JobQueueRoutes(restApp.backend, token),
"item" -> ItemRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token)
"auth" -> LoginRoutes.session(restApp.backend.login, cfg),
"tag" -> TagRoutes(restApp.backend, token),
"equipment" -> EquipmentRoutes(restApp.backend, token),
"organization" -> OrganizationRoutes(restApp.backend, token),
"person" -> PersonRoutes(restApp.backend, token),
"source" -> SourceRoutes(restApp.backend, token),
"user" -> UserRoutes(restApp.backend, token),
"collective" -> CollectiveRoutes(restApp.backend, token),
"queue" -> JobQueueRoutes(restApp.backend, token),
"item" -> ItemRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
"email/settings" -> MailSettingsRoutes(restApp.backend, token)
)
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =

View File

@ -0,0 +1,53 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import org.http4s._
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common._
import docspell.restapi.model._
import docspell.store.records.RUserEmail
import docspell.store.EmilUtil
object MailSettingsRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of {
case req @ GET -> Root =>
val q = req.params.get("q").map(_.trim).filter(_.nonEmpty)
for {
list <- backend.mail.getSettings(user.account, q)
res = list.map(convert)
resp <- Ok(EmailSettingsList(res.toList))
} yield resp
case req @ POST -> Root =>
for {
in <- req.as[EmailSettings]
resp <- Ok(BasicResult(false, "not implemented"))
} yield resp
}
}
def convert(ru: RUserEmail): EmailSettings =
EmailSettings(
ru.name,
ru.smtpHost,
ru.smtpPort,
ru.smtpUser,
ru.smtpPassword,
EmilUtil.mailAddressString(ru.mailFrom),
ru.mailReplyTo.map(EmilUtil.mailAddressString _),
EmilUtil.sslTypeString(ru.smtpSsl),
!ru.smtpCertCheck
)
}

View File

@ -0,0 +1,34 @@
package docspell.store
import emil._
import emil.javamail.syntax._
object EmilUtil {
def readSSLType(str: String): Either[String, SSLType] =
str.toLowerCase match {
case "ssl" => Right(SSLType.SSL)
case "starttls" => Right(SSLType.StartTLS)
case "none" => Right(SSLType.NoEncryption)
case _ => Left(s"Invalid ssl-type: $str")
}
def unsafeReadSSLType(str: String): SSLType =
readSSLType(str).fold(sys.error, identity)
def sslTypeString(st: SSLType): String =
st match {
case SSLType.SSL => "ssl"
case SSLType.StartTLS => "starttls"
case SSLType.NoEncryption => "none"
}
def readMailAddress(str: String): Either[String, MailAddress] =
MailAddress.parse(str)
def unsafeReadMailAddress(str: String): MailAddress =
readMailAddress(str).fold(sys.error, identity)
def mailAddressString(ma: MailAddress): String =
ma.asUnicodeString
}

View File

@ -2,18 +2,15 @@ package docspell.store.impl
import java.time.format.DateTimeFormatter
import java.time.{Instant, LocalDate}
import docspell.common.Timestamp
import docspell.common._
import io.circe.{Decoder, Encoder}
import doobie._
//import doobie.implicits.javatime._
import doobie.implicits.legacy.instant._
import doobie.util.log.Success
import io.circe.{Decoder, Encoder}
import docspell.common.syntax.all._
import emil.{MailAddress, SSLType}
import emil.javamail.syntax._
import docspell.common._
import docspell.common.syntax.all._
import docspell.store.EmilUtil
trait DoobieMeta {
@ -92,29 +89,14 @@ trait DoobieMeta {
Meta[String].imap(Language.unsafe)(_.iso3)
implicit val sslType: Meta[SSLType] =
Meta[String].imap(DoobieMeta.readSSLType)(DoobieMeta.sslTypeString)
Meta[String].imap(EmilUtil.unsafeReadSSLType)(EmilUtil.sslTypeString)
implicit val mailAddress: Meta[MailAddress] =
Meta[String].imap(str => MailAddress.parse(str).fold(sys.error, identity))(_.asUnicodeString)
Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString)
}
object DoobieMeta extends DoobieMeta {
import org.log4s._
private val logger = getLogger
private def readSSLType(str: String): SSLType =
str.toLowerCase match {
case "ssl" => SSLType.SSL
case "starttls" => SSLType.StartTLS
case "none" => SSLType.NoEncryption
case _ => sys.error(s"Invalid ssl-type: $str")
}
private def sslTypeString(st: SSLType): String =
st match {
case SSLType.SSL => "ssl"
case SSLType.StartTLS => "starttls"
case SSLType.NoEncryption => "none"
}
}

View File

@ -2,6 +2,9 @@ package docspell.store.records
import doobie._
import doobie.implicits._
import cats.effect._
import cats.implicits._
import cats.data.OptionT
import docspell.common._
import docspell.store.impl.Column
import docspell.store.impl.Implicits._
@ -10,11 +13,11 @@ import emil.{MailAddress, SSLType}
case class RUserEmail(
id: Ident,
uid: Ident,
name: String,
name: Ident,
smtpHost: String,
smtpPort: Int,
smtpUser: String,
smtpPassword: Password,
smtpPort: Option[Int],
smtpUser: Option[String],
smtpPassword: Option[Password],
smtpSsl: SSLType,
smtpCertCheck: Boolean,
mailFrom: MailAddress,
@ -24,6 +27,67 @@ case class RUserEmail(
object RUserEmail {
def apply[F[_]: Sync](
uid: Ident,
name: Ident,
smtpHost: String,
smtpPort: Option[Int],
smtpUser: Option[String],
smtpPassword: Option[Password],
smtpSsl: SSLType,
smtpCertCheck: Boolean,
mailFrom: MailAddress,
mailReplyTo: Option[MailAddress]
): F[RUserEmail] =
for {
now <- Timestamp.current[F]
id <- Ident.randomId[F]
} yield RUserEmail(
id,
uid,
name,
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpSsl,
smtpCertCheck,
mailFrom,
mailReplyTo,
now
)
def apply(
accId: AccountId,
name: Ident,
smtpHost: String,
smtpPort: Option[Int],
smtpUser: Option[String],
smtpPassword: Option[Password],
smtpSsl: SSLType,
smtpCertCheck: Boolean,
mailFrom: MailAddress,
mailReplyTo: Option[MailAddress]
): OptionT[ConnectionIO, RUserEmail] =
for {
now <- OptionT.liftF(Timestamp.current[ConnectionIO])
id <- OptionT.liftF(Ident.randomId[ConnectionIO])
user <- OptionT(RUser.findByAccount(accId))
} yield RUserEmail(
id,
user.uid,
name,
smtpHost,
smtpPort,
smtpUser,
smtpPassword,
smtpSsl,
smtpCertCheck,
mailFrom,
mailReplyTo,
now
)
val table = fr"useremail"
object Columns {
@ -65,7 +129,54 @@ object RUserEmail {
sql"${v.id},${v.uid},${v.name},${v.smtpHost},${v.smtpPort},${v.smtpUser},${v.smtpPassword},${v.smtpSsl},${v.smtpCertCheck},${v.mailFrom},${v.mailReplyTo},${v.created}"
).update.run
def update(eId: Ident, v: RUserEmail): ConnectionIO[Int] =
updateRow(table, id.is(eId), commas(
name.setTo(v.name),
smtpHost.setTo(v.smtpHost),
smtpPort.setTo(v.smtpPort),
smtpUser.setTo(v.smtpUser),
smtpPass.setTo(v.smtpPassword),
smtpSsl.setTo(v.smtpSsl),
smtpCertCheck.setTo(v.smtpCertCheck),
mailFrom.setTo(v.mailFrom),
mailReplyTo.setTo(v.mailReplyTo)
)).update.run
def findByUser(userId: Ident): ConnectionIO[Vector[RUserEmail]] =
selectSimple(all, table, uid.is(userId)).query[RUserEmail].to[Vector]
private def findByAccount0(
accId: AccountId,
nameQ: Option[String],
exact: Boolean
): Query0[RUserEmail] = {
val mUid = uid.prefix("m")
val mName = name.prefix("m")
val uId = RUser.Columns.uid.prefix("u")
val uColl = RUser.Columns.cid.prefix("u")
val uLogin = RUser.Columns.login.prefix("u")
val from = table ++ fr"m INNER JOIN" ++ RUser.table ++ fr"u ON" ++ mUid.is(uId)
val cond = Seq(uColl.is(accId.collective), uLogin.is(accId.user)) ++ (nameQ match {
case Some(str) if exact => Seq(mName.is(str))
case Some(str) if !exact => Seq(mName.lowerLike(s"%${str.toLowerCase}%"))
case None => Seq.empty
})
selectSimple(all, from, and(cond)).query[RUserEmail]
}
def findByAccount(
accId: AccountId,
nameQ: Option[String]
): ConnectionIO[Vector[RUserEmail]] =
findByAccount0(accId, nameQ, false).to[Vector]
def getByName(accId: AccountId, name: Ident): ConnectionIO[Option[RUserEmail]] =
findByAccount0(accId, Some(name.id), true).option
def exists(accId: AccountId, name: Ident): ConnectionIO[Boolean] =
getByName(accId, name).map(_.isDefined)
def exists(userId: Ident, connName: Ident): ConnectionIO[Boolean] =
selectCount(id, table, and(uid.is(userId), name.is(connName))).query[Int].unique.map(_ > 0)
}

View File

@ -2,19 +2,36 @@ module Comp.EmailSettingsForm exposing
( Model
, Msg
, emptyModel
, getSettings
, init
, update
, view
)
import Api.Model.EmailSettings exposing (EmailSettings)
import Comp.Dropdown
import Comp.IntField
import Comp.PasswordInput
import Data.SSLType exposing (SSLType)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import Html.Events exposing (onCheck, onInput)
import Util.Maybe
type alias Model =
{ settings : EmailSettings
, name : String
, host : String
, portField : Comp.IntField.Model
, portNum : Maybe Int
, user : Maybe String
, passField : Comp.PasswordInput.Model
, password : Maybe String
, from : String
, replyTo : Maybe String
, sslType : Comp.Dropdown.Model SSLType
, ignoreCertificates : Bool
}
@ -22,6 +39,22 @@ emptyModel : Model
emptyModel =
{ settings = Api.Model.EmailSettings.empty
, name = ""
, host = ""
, portField = Comp.IntField.init (Just 0) Nothing "SMTP Port"
, portNum = Nothing
, user = Nothing
, passField = Comp.PasswordInput.init
, password = Nothing
, from = ""
, replyTo = Nothing
, sslType =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s }
, placeholder = ""
, options = Data.SSLType.all
, selected = Just Data.SSLType.None
}
, ignoreCertificates = False
}
@ -29,21 +62,103 @@ init : EmailSettings -> Model
init ems =
{ settings = ems
, name = ems.name
, host = ems.smtpHost
, portField = Comp.IntField.init (Just 0) Nothing "SMTP Port"
, portNum = ems.smtpPort
, user = ems.smtpUser
, passField = Comp.PasswordInput.init
, password = ems.smtpPassword
, from = ems.from
, replyTo = ems.replyTo
, sslType =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s }
, placeholder = ""
, options = Data.SSLType.all
, selected = Data.SSLType.fromString ems.sslType
}
, ignoreCertificates = ems.ignoreCertificates
}
getSettings : Model -> ( Maybe String, EmailSettings )
getSettings model =
( Util.Maybe.fromString model.settings.name
, { name = model.name
, smtpHost = model.host
, smtpUser = model.user
, smtpPort = model.portNum
, smtpPassword = model.password
, from = model.from
, replyTo = model.replyTo
, sslType =
Comp.Dropdown.getSelected model.sslType
|> List.head
|> Maybe.withDefault Data.SSLType.None
|> Data.SSLType.toString
, ignoreCertificates = model.ignoreCertificates
}
)
type Msg
= SetName String
| SetHost String
| PortMsg Comp.IntField.Msg
| SetUser String
| PassMsg Comp.PasswordInput.Msg
| SSLTypeMsg (Comp.Dropdown.Msg SSLType)
| SetFrom String
| SetReplyTo String
| ToggleCheckCert
isValid : Model -> Bool
isValid model =
True
model.host /= "" && model.name /= ""
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
( model, Cmd.none )
case msg of
SetName str ->
( { model | name = str }, Cmd.none )
SetHost str ->
( { model | host = str }, Cmd.none )
PortMsg m ->
let
( pm, val ) =
Comp.IntField.update m model.portField
in
( { model | portField = pm, portNum = val }, Cmd.none )
SetUser str ->
( { model | user = Util.Maybe.fromString str }, Cmd.none )
PassMsg m ->
let
( pm, val ) =
Comp.PasswordInput.update m model.passField
in
( { model | passField = pm, password = val }, Cmd.none )
SSLTypeMsg m ->
let
( sm, sc ) =
Comp.Dropdown.update m model.sslType
in
( { model | sslType = sm }, Cmd.map SSLTypeMsg sc )
SetFrom str ->
( { model | from = str }, Cmd.none )
SetReplyTo str ->
( { model | replyTo = Util.Maybe.fromString str }, Cmd.none )
ToggleCheckCert ->
( { model | ignoreCertificates = not model.ignoreCertificates }, Cmd.none )
view : Model -> Html Msg
@ -61,8 +176,81 @@ view model =
[ type_ "text"
, value model.name
, onInput SetName
, placeholder "Connection name"
, placeholder "Connection name, e.g. 'gmail.com'"
]
[]
, div [ class "ui info message" ]
[ text "The connection name must not contain whitespace or special characters."
]
]
, div [ class "fields" ]
[ div [ class "fifteen wide required field" ]
[ label [] [ text "SMTP Host" ]
, input
[ type_ "text"
, placeholder "SMTP host name, e.g. 'mail.gmail.com'"
, value model.host
, onInput SetHost
]
[]
]
, Html.map PortMsg (Comp.IntField.view model.portNum model.portField)
]
, div [ class "two fields" ]
[ div [ class "field" ]
[ label [] [ text "SMTP User" ]
, input
[ type_ "text"
, placeholder "SMTP Username, e.g. 'your.name@gmail.com'"
, Maybe.withDefault "" model.user |> value
, onInput SetUser
]
[]
]
, div [ class "field" ]
[ label [] [ text "SMTP Password" ]
, Html.map PassMsg (Comp.PasswordInput.view model.password model.passField)
]
]
, div [ class "two fields" ]
[ div [ class "required field" ]
[ label [] [ text "From Address" ]
, input
[ type_ "text"
, placeholder "Sender E-Mail address"
, value model.from
, onInput SetFrom
]
[]
]
, div [ class "field" ]
[ label [] [ text "Reply-To" ]
, input
[ type_ "text"
, placeholder "Optional reply-to E-Mail address"
, Maybe.withDefault "" model.replyTo |> value
, onInput SetReplyTo
]
[]
]
]
, div [ class "two fields" ]
[ div [ class "inline field" ]
[ div [ class "ui checkbox" ]
[ input
[ type_ "checkbox"
, checked model.ignoreCertificates
, onCheck (\_ -> ToggleCheckCert)
]
[]
, label [] [ text "Ignore certificate check" ]
]
]
]
, div [ class "two fields" ]
[ div [ class "field" ]
[ label [] [ text "SSL" ]
, Html.map SSLTypeMsg (Comp.Dropdown.view model.sslType)
]
]
]

View File

@ -17,6 +17,7 @@ import Comp.YesNoDimmer
import Data.Flags exposing (Flags)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput)
type alias Model =
@ -25,6 +26,7 @@ type alias Model =
, viewMode : ViewMode
, formError : Maybe String
, loading : Bool
, query : String
, deleteConfirm : Comp.YesNoDimmer.Model
}
@ -36,6 +38,7 @@ emptyModel =
, viewMode = Table
, formError = Nothing
, loading = False
, query = ""
, deleteConfirm = Comp.YesNoDimmer.emptyModel
}
@ -53,18 +56,139 @@ type ViewMode
type Msg
= TableMsg Comp.EmailSettingsTable.Msg
| FormMsg Comp.EmailSettingsForm.Msg
| SetQuery String
| InitNew
| YesNoMsg Comp.YesNoDimmer.Msg
| RequestDelete
| SetViewMode ViewMode
update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
update flags msg model =
( model, Cmd.none )
case msg of
InitNew ->
let
ems =
Api.Model.EmailSettings.empty
nm =
{ model
| viewMode = Form
, formError = Nothing
, formModel = Comp.EmailSettingsForm.init ems
}
in
( nm, Cmd.none )
TableMsg m ->
let
( tm, tc ) =
Comp.EmailSettingsTable.update m model.tableModel
in
( { model | tableModel = tm }, Cmd.map TableMsg tc )
FormMsg m ->
let
( fm, fc ) =
Comp.EmailSettingsForm.update m model.formModel
in
( { model | formModel = fm }, Cmd.map FormMsg fc )
SetQuery str ->
( { model | query = str }, Cmd.none )
YesNoMsg m ->
let
( dm, flag ) =
Comp.YesNoDimmer.update m model.deleteConfirm
in
( { model | deleteConfirm = dm }, Cmd.none )
RequestDelete ->
( model, Cmd.none )
SetViewMode m ->
( { model | viewMode = m }, Cmd.none )
view : Model -> Html Msg
view model =
case model.viewMode of
Table ->
Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel)
viewTable model
Form ->
Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel)
viewForm model
viewTable : Model -> Html Msg
viewTable model =
div []
[ div [ class "ui secondary menu container" ]
[ div [ class "ui container" ]
[ div [ class "fitted-item" ]
[ div [ class "ui icon input" ]
[ input
[ type_ "text"
, onInput SetQuery
, value model.query
, placeholder "Search"
]
[]
, i [ class "ui search icon" ]
[]
]
]
, div [ class "right menu" ]
[ div [ class "fitted-item" ]
[ a
[ class "ui primary button"
, href "#"
, onClick InitNew
]
[ i [ class "plus icon" ] []
, text "New Settings"
]
]
]
]
]
, Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel)
]
viewForm : Model -> Html Msg
viewForm model =
div [ class "ui segment" ]
[ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm)
, Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel)
, div
[ classList
[ ( "ui error message", True )
, ( "invisible", model.formError == Nothing )
]
]
[ Maybe.withDefault "" model.formError |> text
]
, div [ class "ui divider" ] []
, button [ class "ui primary button" ]
[ text "Submit"
]
, a [ class "ui secondary button", onClick (SetViewMode Table), href "" ]
[ text "Cancel"
]
, if model.formModel.settings.name /= "" then
a [ class "ui right floated red button", href "", onClick RequestDelete ]
[ text "Delete" ]
else
span [] []
, div
[ classList
[ ( "ui dimmer", True )
, ( "active", model.loading )
]
]
[ div [ class "ui loader" ] []
]
]

View File

@ -0,0 +1,108 @@
module Comp.IntField exposing (Model, Msg, init, update, view)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
type alias Model =
{ min : Maybe Int
, max : Maybe Int
, label : String
, error : Maybe String
, lastInput : String
}
type Msg
= SetValue String
init : Maybe Int -> Maybe Int -> String -> Model
init min max label =
{ min = min
, max = max
, label = label
, error = Nothing
, lastInput = ""
}
tooLow : Model -> Int -> Bool
tooLow model n =
Maybe.map ((<) n) model.min
|> Maybe.withDefault False
tooHigh : Model -> Int -> Bool
tooHigh model n =
Maybe.map ((>) n) model.max
|> Maybe.withDefault False
update : Msg -> Model -> ( Model, Maybe Int )
update msg model =
let
tooHighError =
Maybe.withDefault 0 model.max
|> String.fromInt
|> (++) "Number must be <= "
tooLowError =
Maybe.withDefault 0 model.min
|> String.fromInt
|> (++) "Number must be >= "
in
case msg of
SetValue str ->
let
m =
{ model | lastInput = str }
in
case String.toInt str of
Just n ->
if tooLow model n then
( { m | error = Just tooLowError }
, Nothing
)
else if tooHigh model n then
( { m | error = Just tooHighError }
, Nothing
)
else
( { m | error = Nothing }, Just n )
Nothing ->
( { m | error = Just ("'" ++ str ++ "' is not a valid number!") }
, Nothing
)
view : Maybe Int -> Model -> Html Msg
view nval model =
div
[ classList
[ ( "field", True )
, ( "error", model.error /= Nothing )
]
]
[ label [] [ text model.label ]
, input
[ type_ "text"
, Maybe.map String.fromInt nval
|> Maybe.withDefault model.lastInput
|> value
, onInput SetValue
]
[]
, div
[ classList
[ ( "ui pointing red basic label", True )
, ( "hidden", model.error == Nothing )
]
]
[ Maybe.withDefault "" model.error |> text
]
]

View File

@ -0,0 +1,74 @@
module Comp.PasswordInput exposing
( Model
, Msg
, init
, update
, view
)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput)
import Util.Maybe
type alias Model =
{ show : Bool
}
init : Model
init =
{ show = False
}
type Msg
= ToggleShow (Maybe String)
| SetPassword String
update : Msg -> Model -> ( Model, Maybe String )
update msg model =
case msg of
ToggleShow pw ->
( { model | show = not model.show }
, pw
)
SetPassword str ->
let
pw =
Util.Maybe.fromString str
in
( model, pw )
view : Maybe String -> Model -> Html Msg
view pw model =
div [ class "ui left action input" ]
[ button
[ class "ui icon button"
, type_ "button"
, onClick (ToggleShow pw)
]
[ i
[ classList
[ ( "ui eye icon", True )
, ( "slash", model.show )
]
]
[]
]
, input
[ type_ <|
if model.show then
"text"
else
"password"
, onInput SetPassword
, Maybe.withDefault "" pw |> value
]
[]
]

View File

@ -0,0 +1,60 @@
module Data.SSLType exposing
( SSLType(..)
, all
, fromString
, label
, toString
)
type SSLType
= None
| SSL
| StartTLS
all : List SSLType
all =
[ None, SSL, StartTLS ]
toString : SSLType -> String
toString st =
case st of
None ->
"none"
SSL ->
"ssl"
StartTLS ->
"starttls"
fromString : String -> Maybe SSLType
fromString str =
case String.toLower str of
"none" ->
Just None
"ssl" ->
Just SSL
"starttls" ->
Just StartTLS
_ ->
Nothing
label : SSLType -> String
label st =
case st of
None ->
"None"
SSL ->
"SSL/TLS"
StartTLS ->
"StartTLS"

View File

@ -1,5 +1,6 @@
module Util.Maybe exposing
( isEmpty
( fromString
, isEmpty
, nonEmpty
, or
, withDefault
@ -38,3 +39,16 @@ or listma =
Nothing ->
or els
fromString : String -> Maybe String
fromString str =
let
s =
String.trim str
in
if s == "" then
Nothing
else
Just str