From f235f3a0307d65cd12227105c8097a6218462529 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 5 Jan 2020 23:23:28 +0100 Subject: [PATCH] Starting with mail functionality --- .../scala/docspell/backend/BackendApp.scala | 3 + .../scala/docspell/backend/ops/OMail.scala | 43 ++++ .../src/main/resources/docspell-openapi.yml | 14 +- .../docspell/restserver/RestServer.scala | 27 +-- .../routes/MailSettingsRoutes.scala | 53 +++++ .../main/scala/docspell/store/EmilUtil.scala | 34 +++ .../docspell/store/impl/DoobieMeta.scala | 32 +-- .../docspell/store/records/RUserEmail.scala | 119 ++++++++++- .../src/main/elm/Comp/EmailSettingsForm.elm | 196 +++++++++++++++++- .../src/main/elm/Comp/EmailSettingsManage.elm | 130 +++++++++++- modules/webapp/src/main/elm/Comp/IntField.elm | 108 ++++++++++ .../src/main/elm/Comp/PasswordInput.elm | 74 +++++++ modules/webapp/src/main/elm/Data/SSLType.elm | 60 ++++++ modules/webapp/src/main/elm/Util/Maybe.elm | 16 +- 14 files changed, 853 insertions(+), 56 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OMail.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala create mode 100644 modules/store/src/main/scala/docspell/store/EmilUtil.scala create mode 100644 modules/webapp/src/main/elm/Comp/IntField.elm create mode 100644 modules/webapp/src/main/elm/Comp/PasswordInput.elm create mode 100644 modules/webapp/src/main/elm/Data/SSLType.elm diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 05ed9347..da8958cc 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -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]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala new file mode 100644 index 00000000..ae3eabff --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -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)) + } + }) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 9f8383f5..039cbca7 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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: | diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index c88dfa9b..435810db 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -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] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala new file mode 100644 index 00000000..eed7d114 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala @@ -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 + ) +} diff --git a/modules/store/src/main/scala/docspell/store/EmilUtil.scala b/modules/store/src/main/scala/docspell/store/EmilUtil.scala new file mode 100644 index 00000000..25339c0f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/EmilUtil.scala @@ -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 +} diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 98d3b594..06fb93c5 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -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" - } - } diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala index 5f564b4f..98cd9dfb 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -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) } diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm index c3785eca..3b83578c 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm @@ -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) + ] ] ] diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm index 293d2df8..ba2cc6ed 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm @@ -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" ] [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/IntField.elm b/modules/webapp/src/main/elm/Comp/IntField.elm new file mode 100644 index 00000000..8e519034 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/IntField.elm @@ -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 + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/PasswordInput.elm b/modules/webapp/src/main/elm/Comp/PasswordInput.elm new file mode 100644 index 00000000..06f8fc94 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/PasswordInput.elm @@ -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 + ] + [] + ] diff --git a/modules/webapp/src/main/elm/Data/SSLType.elm b/modules/webapp/src/main/elm/Data/SSLType.elm new file mode 100644 index 00000000..35327f56 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/SSLType.elm @@ -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" diff --git a/modules/webapp/src/main/elm/Util/Maybe.elm b/modules/webapp/src/main/elm/Util/Maybe.elm index 00de8c40..8515fdf7 100644 --- a/modules/webapp/src/main/elm/Util/Maybe.elm +++ b/modules/webapp/src/main/elm/Util/Maybe.elm @@ -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