Merge pull request #129 from eikek/integration-endpoint

Integration endpoint
This commit is contained in:
eikek 2020-05-23 15:00:03 +02:00 committed by GitHub
commit e2fc13673e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 437 additions and 84 deletions

View File

@ -6,6 +6,8 @@
- New feature "Scan Mailboxes". Docspell can now read mailboxes - New feature "Scan Mailboxes". Docspell can now read mailboxes
periodically to import your mails. periodically to import your mails.
- New feature "Integration Endpoint". Allows an admin to upload files
to any collective using a separate endpoint.
- Fix the `find-by-checksum` route that, given a sha256 checksum, - Fix the `find-by-checksum` route that, given a sha256 checksum,
returns whether there is such a file in docspell. It falsely returns whether there is such a file in docspell. It falsely
returned `false` although documents existed. returned `false` although documents existed.
@ -21,7 +23,7 @@
### Configuration Changes ### Configuration Changes
The joex component has new config sections: The joex and rest-server component have new config sections:
- Add `docspell.joex.mail-debug` flag to enable debugging e-mail - Add `docspell.joex.mail-debug` flag to enable debugging e-mail
related code. This is only useful if you encounter problems related code. This is only useful if you encounter problems
@ -30,6 +32,8 @@ The joex component has new config sections:
configure the new scan-mailbox user task. configure the new scan-mailbox user task.
- Add `docspell.joex.files` section that is the same as the - Add `docspell.joex.files` section that is the same as the
corresponding section in the rest server config. corresponding section in the rest server config.
- Add `docspell.rest-server.integration-endpoint` with sub-sections to
configure an endpoint for uploading files for admin users.
### REST Api Changes ### REST Api Changes
@ -37,6 +41,9 @@ The joex component has new config sections:
- Add `/sec/email/settings/imap` - Add `/sec/email/settings/imap`
- Add `/sec/usertask/scanmailbox` routes to configure one or more - Add `/sec/usertask/scanmailbox` routes to configure one or more
scan-mailbox tasks scan-mailbox tasks
- The data used in `/sec/collective/settings` was extended with a
boolean value to enable/disable the "integration endpoint" for a
collective.
## v0.5.0 ## v0.5.0

View File

@ -14,7 +14,7 @@ trait OCollective[F[_]] {
def find(name: Ident): F[Option[RCollective]] def find(name: Ident): F[Option[RCollective]]
def updateLanguage(collective: Ident, lang: Language): F[AddResult] def updateSettings(collective: Ident, lang: OCollective.Settings): F[AddResult]
def listUser(collective: Ident): F[Vector[RUser]] def listUser(collective: Ident): F[Vector[RUser]]
@ -45,6 +45,9 @@ object OCollective {
type InsightData = QCollective.InsightData type InsightData = QCollective.InsightData
val insightData = QCollective.InsightData val insightData = QCollective.InsightData
type Settings = RCollective.Settings
val Settings = RCollective.Settings
sealed trait PassChangeResult sealed trait PassChangeResult
object PassChangeResult { object PassChangeResult {
case object UserNotFound extends PassChangeResult case object UserNotFound extends PassChangeResult
@ -85,9 +88,9 @@ object OCollective {
def find(name: Ident): F[Option[RCollective]] = def find(name: Ident): F[Option[RCollective]] =
store.transact(RCollective.findById(name)) store.transact(RCollective.findById(name))
def updateLanguage(collective: Ident, lang: Language): F[AddResult] = def updateSettings(collective: Ident, sett: Settings): F[AddResult] =
store store
.transact(RCollective.updateLanguage(collective, lang)) .transact(RCollective.updateSettings(collective, sett))
.attempt .attempt
.map(AddResult.fromUpdate) .map(AddResult.fromUpdate)

View File

@ -88,7 +88,13 @@ object OSignup {
for { for {
id2 <- Ident.randomId[F] id2 <- Ident.randomId[F]
now <- Timestamp.current[F] now <- Timestamp.current[F]
c = RCollective(data.collName, CollectiveState.Active, Language.German, now) c = RCollective(
data.collName,
CollectiveState.Active,
Language.German,
true,
now
)
u = RUser( u = RUser(
id2, id2,
data.login, data.login,

View File

@ -40,6 +40,9 @@ object Implicits {
implicit val caleventReader: ConfigReader[CalEvent] = implicit val caleventReader: ConfigReader[CalEvent] =
ConfigReader[String].emap(reason(CalEvent.parse)) ConfigReader[String].emap(reason(CalEvent.parse))
implicit val priorityReader: ConfigReader[Priority] =
ConfigReader[String].emap(reason(Priority.fromString))
def reason[A: ClassTag]( def reason[A: ClassTag](
f: String => Either[String, A] f: String => Either[String, A]
): String => Either[FailureReason, A] = ): String => Either[FailureReason, A] =

View File

@ -85,6 +85,31 @@ docspell count the files uploaded through the web interface, just
create a source (can be inactive) with that name (`webapp`). create a source (can be inactive) with that name (`webapp`).
## Integration Endpoint
Another option for uploading files is the special *integration
endpoint*. This endpoint allows an admin to upload files to any
collective, that is known by name.
```
/api/v1/open/integration/item/[collective-name]
```
The endpoint is behind `/api/v1/open`, so this route is not protected
by an authentication token (see [REST Api](../api) for more
information). However, it can be protected via settings in the
configuration file. The idea is that this endpoint is controlled by an
administrator and not the user of the application. The admin can
enable this endpoint and choose between some methods to protect it.
Then the administrator can upload files to any collective. This might
be useful to connect other trusted applications to docspell (that run
on the same host or network).
The endpoint is disabled by default, an admin must change the
`docspell.restserver.integration-endpoint.enabled` flag to `true` in
the [configuration file](configure#rest-server).
## The Request ## The Request
This gives more details about the request for uploads. It is a http This gives more details about the request for uploads. It is a http

View File

@ -618,10 +618,9 @@ paths:
$ref: "#/components/schemas/CollectiveSettings" $ref: "#/components/schemas/CollectiveSettings"
post: post:
tags: [ Collective ] tags: [ Collective ]
summary: Set document language of the collective summary: Update settings for a collective
description: | description: |
Updates settings for a collective, which currently is just the Updates settings for a collective.
document language.
security: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:
@ -2692,10 +2691,16 @@ components:
Settings for a collective. Settings for a collective.
required: required:
- language - language
- integrationEnabled
properties: properties:
language: language:
type: string type: string
format: language format: language
integrationEnabled:
type: boolean
description: |
Whether the collective has the integration endpoint
enabled.
SourceList: SourceList:
description: | description: |
A list of sources. A list of sources.

View File

@ -31,6 +31,52 @@ docspell.server {
session-valid = "5 minutes" session-valid = "5 minutes"
} }
# This endpoint allows to upload files to any collective. The
# intention is that local software integrates with docspell more
# easily. Therefore the endpoint is not protected by the usual
# means.
#
# For security reasons, this endpoint is disabled by default. If
# enabled, you can choose from some ways to protect it. It may be a
# good idea to further protect this endpoint using a firewall, such
# that outside traffic is not routed.
#
# NOTE: If all protection methods are disabled, the endpoint is not
# protected at all!
integration-endpoint {
enabled = false
# The priority to use when submitting files through this endpoint.
priority = "low"
# IPv4 addresses to allow access. An empty list, if enabled,
# prohibits all requests. IP addresses may be specified as simple
# globs: a part marked as `*' matches any octet, like in
# `192.168.*.*`. The `127.0.0.1' (the default) matches the
# loopback address.
allowed-ips {
enabled = true
ips = [ "127.0.0.1" ]
}
# Requests are expected to use http basic auth when uploading
# files.
http-basic {
enabled = false
realm = "Docspell Integration"
user = "docspell-int"
password = "docspell-int"
}
# Requests are expected to supply some specific header when
# uploading files.
http-header {
enabled = false
header-name = "Docspell-Integration"
header-value = "some-secret"
}
}
# Configuration for the backend. # Configuration for the backend.
backend { backend {

View File

@ -1,5 +1,6 @@
package docspell.restserver package docspell.restserver
import java.net.InetAddress
import docspell.backend.auth.Login import docspell.backend.auth.Login
import docspell.backend.{Config => BackendConfig} import docspell.backend.{Config => BackendConfig}
import docspell.common._ import docspell.common._
@ -10,10 +11,42 @@ case class Config(
baseUrl: LenientUri, baseUrl: LenientUri,
bind: Config.Bind, bind: Config.Bind,
backend: BackendConfig, backend: BackendConfig,
auth: Login.Config auth: Login.Config,
integrationEndpoint: Config.IntegrationEndpoint
) )
object Config { object Config {
case class Bind(address: String, port: Int) case class Bind(address: String, port: Int)
case class IntegrationEndpoint(
enabled: Boolean,
priority: Priority,
allowedIps: IntegrationEndpoint.AllowedIps,
httpBasic: IntegrationEndpoint.HttpBasic,
httpHeader: IntegrationEndpoint.HttpHeader
)
object IntegrationEndpoint {
case class HttpBasic(enabled: Boolean, realm: String, user: String, password: String)
case class HttpHeader(enabled: Boolean, headerName: String, headerValue: String)
case class AllowedIps(enabled: Boolean, ips: Set[String]) {
def containsAddress(inet: InetAddress): Boolean = {
val ip = inet.getHostAddress
lazy val ipParts = ip.split('.')
def checkSingle(pattern: String): Boolean =
pattern == ip || (inet.isLoopbackAddress && pattern == "127.0.0.1") || (pattern
.split('.')
.zip(ipParts)
.foldLeft(true) {
case (r, (a, b)) =>
r && (a == "*" || a == b)
})
ips.exists(checkSingle)
}
}
}
} }

View File

@ -83,10 +83,11 @@ object RestServer {
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
Router( Router(
"auth" -> LoginRoutes.login(restApp.backend.login, cfg), "auth" -> LoginRoutes.login(restApp.backend.login, cfg),
"signup" -> RegisterRoutes(restApp.backend, cfg), "signup" -> RegisterRoutes(restApp.backend, cfg),
"upload" -> UploadRoutes.open(restApp.backend, cfg), "upload" -> UploadRoutes.open(restApp.backend, cfg),
"checkfile" -> CheckFileRoutes.open(restApp.backend) "checkfile" -> CheckFileRoutes.open(restApp.backend),
"integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg)
) )
def redirectTo[F[_]: Effect](path: String): HttpRoutes[F] = { def redirectTo[F[_]: Effect](path: String): HttpRoutes[F] = {

View File

@ -0,0 +1,29 @@
package docspell.restserver.http4s
import fs2.{Pure, Stream}
import fs2.text.utf8Encode
import org.http4s._
import org.http4s.headers._
object Responses {
private[this] val pureForbidden: Response[Pure] =
Response(
Status.Forbidden,
body = Stream("Forbidden").through(utf8Encode),
headers = Headers(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`) :: Nil)
)
private[this] val pureUnauthorized: Response[Pure] =
Response(
Status.Unauthorized,
body = Stream("Unauthorized").through(utf8Encode),
headers = Headers(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`) :: Nil)
)
def forbidden[F[_]]: Response[F] =
pureForbidden.copy(body = pureForbidden.body.covary[F])
def unauthorized[F[_]]: Response[F] =
pureUnauthorized.copy(body = pureUnauthorized.body.covary[F])
}

View File

@ -4,6 +4,7 @@ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCollective
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.conv.Conversions import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._ import docspell.restserver.http4s._
@ -28,16 +29,17 @@ object CollectiveRoutes {
case req @ POST -> Root / "settings" => case req @ POST -> Root / "settings" =>
for { for {
settings <- req.as[CollectiveSettings] settings <- req.as[CollectiveSettings]
sett = OCollective.Settings(settings.language, settings.integrationEnabled)
res <- res <-
backend.collective backend.collective
.updateLanguage(user.account.collective, settings.language) .updateSettings(user.account.collective, sett)
resp <- Ok(Conversions.basicResult(res, "Language updated.")) resp <- Ok(Conversions.basicResult(res, "Settings updated."))
} yield resp } yield resp
case GET -> Root / "settings" => case GET -> Root / "settings" =>
for { for {
collDb <- backend.collective.find(user.account.collective) collDb <- backend.collective.find(user.account.collective)
sett = collDb.map(c => CollectiveSettings(c.language)) sett = collDb.map(c => CollectiveSettings(c.language, c.integrationEnabled))
resp <- sett.toResponse() resp <- sett.toResponse()
} yield resp } yield resp

View File

@ -0,0 +1,150 @@
package docspell.restserver.routes
import cats.data.{EitherT, OptionT}
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.common._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.Responses
import org.http4s._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
import org.http4s.EntityDecoder._
import org.http4s.headers.{Authorization, `WWW-Authenticate`}
import org.http4s.multipart.Multipart
import org.http4s.util.CaseInsensitiveString
import org.log4s.getLogger
object IntegrationEndpointRoutes {
private[this] val logger = getLogger
def open[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of {
case req @ POST -> Root / "item" / Ident(collective) =>
(for {
_ <- checkEnabled(cfg.integrationEndpoint)
_ <- authRequest(req, cfg.integrationEndpoint)
_ <- lookupCollective(collective, backend)
res <- EitherT.liftF[F, Response[F], Response[F]](
uploadFile(collective, backend, cfg, dsl)(req)
)
} yield res).fold(identity, identity)
}
}
def checkEnabled[F[_]: Effect](
cfg: Config.IntegrationEndpoint
): EitherT[F, Response[F], Unit] =
EitherT.cond[F](cfg.enabled, (), Response.notFound[F])
def authRequest[F[_]: Effect](
req: Request[F],
cfg: Config.IntegrationEndpoint
): EitherT[F, Response[F], Unit] = {
val service =
SourceIpAuth[F](cfg.allowedIps) <+> HeaderAuth(cfg.httpHeader) <+> HttpBasicAuth(
cfg.httpBasic
)
service.run(req).toLeft(())
}
def lookupCollective[F[_]: Effect](
coll: Ident,
backend: BackendApp[F]
): EitherT[F, Response[F], Unit] =
for {
opt <- EitherT.liftF(backend.collective.find(coll))
res <- EitherT.cond[F](opt.exists(_.integrationEnabled), (), Response.notFound[F])
} yield res
def uploadFile[F[_]: Effect](
coll: Ident,
backend: BackendApp[F],
cfg: Config,
dsl: Http4sDsl[F]
)(
req: Request[F]
): F[Response[F]] = {
import dsl._
for {
multipart <- req.as[Multipart[F]]
updata <- readMultipart(
multipart,
logger,
cfg.integrationEndpoint.priority,
cfg.backend.files.validMimeTypes
)
account = AccountId(coll, Ident.unsafe("docspell-system"))
result <- backend.upload.submit(updata, account, true)
res <- Ok(basicResult(result))
} yield res
}
object HeaderAuth {
def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.HttpHeader): HttpRoutes[F] =
if (cfg.enabled) checkHeader(cfg)
else HttpRoutes.empty[F]
def checkHeader[F[_]: Effect](
cfg: Config.IntegrationEndpoint.HttpHeader
): HttpRoutes[F] =
HttpRoutes { req =>
val h = req.headers.find(_.name == CaseInsensitiveString(cfg.headerName))
if (h.exists(_.value == cfg.headerValue)) OptionT.none[F, Response[F]]
else OptionT.pure(Responses.forbidden[F])
}
}
object SourceIpAuth {
def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.AllowedIps): HttpRoutes[F] =
if (cfg.enabled) checkIps(cfg)
else HttpRoutes.empty[F]
def checkIps[F[_]: Effect](
cfg: Config.IntegrationEndpoint.AllowedIps
): HttpRoutes[F] =
HttpRoutes { req =>
//The `req.from' take the X-Forwarded-For header into account,
//which is not desirable here. The `http-header' auth config
//can be used to authenticate based on headers.
val from = req.remote.flatMap(remote => Option(remote.getAddress))
if (from.exists(cfg.containsAddress)) OptionT.none[F, Response[F]]
else OptionT.pure(Responses.forbidden[F])
}
}
object HttpBasicAuth {
def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.HttpBasic): HttpRoutes[F] =
if (cfg.enabled) checkHttpBasic(cfg)
else HttpRoutes.empty[F]
def checkHttpBasic[F[_]: Effect](
cfg: Config.IntegrationEndpoint.HttpBasic
): HttpRoutes[F] =
HttpRoutes { req =>
req.headers.get(Authorization) match {
case Some(auth) =>
auth.credentials match {
case BasicCredentials(user, pass)
if user == cfg.user && pass == cfg.password =>
OptionT.none[F, Response[F]]
case _ =>
OptionT.pure(Responses.forbidden[F])
}
case None =>
OptionT.pure(
Responses
.unauthorized[F]
.withHeaders(
`WWW-Authenticate`(Challenge("Basic", cfg.realm))
)
)
}
}
}
}

View File

@ -5,7 +5,6 @@ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.common.{Ident, Priority} import docspell.common.{Ident, Priority}
import docspell.restapi.model.BasicResult
import docspell.restserver.Config import docspell.restserver.Config
import docspell.restserver.conv.Conversions._ import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator import docspell.restserver.http4s.ResponseGenerator
@ -40,10 +39,6 @@ object UploadRoutes {
result <- backend.upload.submit(updata, user.account, true) result <- backend.upload.submit(updata, user.account, true)
res <- Ok(basicResult(result)) res <- Ok(basicResult(result))
} yield res } yield res
case GET -> Root / "checkfile" / checksum =>
Ok(BasicResult(false, s"not implemented $checksum"))
} }
} }
@ -64,9 +59,6 @@ object UploadRoutes {
result <- backend.upload.submit(updata, id, true) result <- backend.upload.submit(updata, id, true)
res <- Ok(basicResult(result)) res <- Ok(basicResult(result))
} yield res } yield res
case GET -> Root / "checkfile" / Ident(id) / checksum =>
Ok(BasicResult(false, s"not implemented $id $checksum"))
} }
} }
} }

View File

@ -12,7 +12,8 @@ case class Flags(
appName: String, appName: String,
baseUrl: LenientUri, baseUrl: LenientUri,
signupMode: SignupConfig.Mode, signupMode: SignupConfig.Mode,
docspellAssetPath: String docspellAssetPath: String,
integrationEnabled: Boolean
) )
object Flags { object Flags {
@ -21,7 +22,8 @@ object Flags {
cfg.appName, cfg.appName,
cfg.baseUrl, cfg.baseUrl,
cfg.backend.signup.mode, cfg.backend.signup.mode,
s"/app/assets/docspell-webapp/${BuildInfo.version}" s"/app/assets/docspell-webapp/${BuildInfo.version}",
cfg.integrationEndpoint.enabled
) )
implicit val jsonEncoder: Encoder[Flags] = implicit val jsonEncoder: Encoder[Flags] =

View File

@ -0,0 +1,7 @@
ALTER TABLE `collective`
ADD COLUMN (`integration_enabled` BOOLEAN);
UPDATE `collective` SET `integration_enabled` = true;
ALTER TABLE `collective`
MODIFY `integration_enabled` BOOLEAN NOT NULL;

View File

@ -0,0 +1,7 @@
ALTER TABLE "collective"
ADD COLUMN "integration_enabled" BOOLEAN;
UPDATE "collective" SET "integration_enabled" = true;
ALTER TABLE "collective"
ALTER COLUMN "integration_enabled" SET NOT NULL;

View File

@ -11,6 +11,7 @@ case class RCollective(
id: Ident, id: Ident,
state: CollectiveState, state: CollectiveState,
language: Language, language: Language,
integrationEnabled: Boolean,
created: Timestamp created: Timestamp
) )
@ -20,12 +21,13 @@ object RCollective {
object Columns { object Columns {
val id = Column("cid") val id = Column("cid")
val state = Column("state") val state = Column("state")
val language = Column("doclang") val language = Column("doclang")
val created = Column("created") val integration = Column("integration_enabled")
val created = Column("created")
val all = List(id, state, language, created) val all = List(id, state, language, integration, created)
} }
import Columns._ import Columns._
@ -34,7 +36,7 @@ object RCollective {
val sql = insertRow( val sql = insertRow(
table, table,
Columns.all, Columns.all,
fr"${value.id},${value.state},${value.language},${value.created}" fr"${value.id},${value.state},${value.language},${value.integrationEnabled},${value.created}"
) )
sql.update.run sql.update.run
} }
@ -56,6 +58,16 @@ object RCollective {
def updateLanguage(cid: Ident, lang: Language): ConnectionIO[Int] = def updateLanguage(cid: Ident, lang: Language): ConnectionIO[Int] =
updateRow(table, id.is(cid), language.setTo(lang)).update.run updateRow(table, id.is(cid), language.setTo(lang)).update.run
def updateSettings(cid: Ident, settings: Settings): ConnectionIO[Int] =
updateRow(
table,
id.is(cid),
commas(
language.setTo(settings.language),
integration.setTo(settings.integrationEnabled)
)
).update.run
def findById(cid: Ident): ConnectionIO[Option[RCollective]] = { def findById(cid: Ident): ConnectionIO[Option[RCollective]] = {
val sql = selectSimple(all, table, id.is(cid)) val sql = selectSimple(all, table, id.is(cid))
sql.query[RCollective].option sql.query[RCollective].option
@ -75,4 +87,6 @@ object RCollective {
val sql = selectSimple(all, table, Fragment.empty) ++ orderBy(order(Columns).f) val sql = selectSimple(all, table, Fragment.empty) ++ orderBy(order(Columns).f)
sql.query[RCollective].stream sql.query[RCollective].stream
} }
case class Settings(language: Language, integrationEnabled: Boolean)
} }

View File

@ -1,4 +1,4 @@
module Comp.Settings exposing module Comp.CollectiveSettingsForm exposing
( Model ( Model
, Msg , Msg
, getSettings , getSettings
@ -13,10 +13,12 @@ import Data.Flags exposing (Flags)
import Data.Language exposing (Language) import Data.Language exposing (Language)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onCheck)
type alias Model = type alias Model =
{ langModel : Comp.Dropdown.Model Language { langModel : Comp.Dropdown.Model Language
, intEnabled : Bool
, initSettings : CollectiveSettings , initSettings : CollectiveSettings
} }
@ -39,6 +41,7 @@ init settings =
, options = Data.Language.all , options = Data.Language.all
, selected = Just lang , selected = Just lang
} }
, intEnabled = settings.integrationEnabled
, initSettings = settings , initSettings = settings
} }
@ -51,10 +54,12 @@ getSettings model =
|> Maybe.map Data.Language.toIso3 |> Maybe.map Data.Language.toIso3
|> Maybe.withDefault model.initSettings.language |> Maybe.withDefault model.initSettings.language
) )
model.intEnabled
type Msg type Msg
= LangDropdownMsg (Comp.Dropdown.Msg Language) = LangDropdownMsg (Comp.Dropdown.Msg Language)
| ToggleIntegrationEndpoint
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings ) update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings )
@ -77,12 +82,42 @@ update _ msg model =
in in
( nextModel, Cmd.map LangDropdownMsg c2, nextSettings ) ( nextModel, Cmd.map LangDropdownMsg c2, nextSettings )
ToggleIntegrationEndpoint ->
let
nextModel =
{ model | intEnabled = not model.intEnabled }
in
( nextModel, Cmd.none, Just (getSettings nextModel) )
view : Model -> Html Msg
view model = view : Flags -> Model -> Html Msg
view flags model =
div [ class "ui form" ] div [ class "ui form" ]
[ div [ class "field" ] [ div [ class "field" ]
[ label [] [ text "Document Language" ] [ label [] [ text "Document Language" ]
, Html.map LangDropdownMsg (Comp.Dropdown.view model.langModel) , Html.map LangDropdownMsg (Comp.Dropdown.view model.langModel)
, span [ class "small-info" ]
[ text "The language of your documents. This helps text recognition (OCR) and text analysis."
]
]
, div
[ classList
[ ( "field", True )
, ( "invisible hidden", not flags.config.integrationEnabled )
]
]
[ div [ class "ui checkbox" ]
[ input
[ type_ "checkbox"
, onCheck (\_ -> ToggleIntegrationEndpoint)
, checked model.intEnabled
]
[]
, label [] [ text "Enable integration endpoint" ]
, span [ class "small-info" ]
[ text "The integration endpoint allows (local) applications to submit files. "
, text "You can choose to disable it for your collective."
]
]
] ]
] ]

View File

@ -14,6 +14,7 @@ type alias Config =
, baseUrl : String , baseUrl : String
, signupMode : String , signupMode : String
, docspellAssetPath : String , docspellAssetPath : String
, integrationEnabled : Bool
} }

View File

@ -8,7 +8,7 @@ module Page.CollectiveSettings.Data exposing
import Api.Model.BasicResult exposing (BasicResult) import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Api.Model.CollectiveSettings exposing (CollectiveSettings)
import Api.Model.ItemInsights exposing (ItemInsights) import Api.Model.ItemInsights exposing (ItemInsights)
import Comp.Settings import Comp.CollectiveSettingsForm
import Comp.SourceManage import Comp.SourceManage
import Comp.UserManage import Comp.UserManage
import Http import Http
@ -18,7 +18,7 @@ type alias Model =
{ currentTab : Maybe Tab { currentTab : Maybe Tab
, sourceModel : Comp.SourceManage.Model , sourceModel : Comp.SourceManage.Model
, userModel : Comp.UserManage.Model , userModel : Comp.UserManage.Model
, settingsModel : Comp.Settings.Model , settingsModel : Comp.CollectiveSettingsForm.Model
, insights : ItemInsights , insights : ItemInsights
, submitResult : Maybe BasicResult , submitResult : Maybe BasicResult
} }
@ -29,7 +29,7 @@ emptyModel =
{ currentTab = Just InsightsTab { currentTab = Just InsightsTab
, sourceModel = Comp.SourceManage.emptyModel , sourceModel = Comp.SourceManage.emptyModel
, userModel = Comp.UserManage.emptyModel , userModel = Comp.UserManage.emptyModel
, settingsModel = Comp.Settings.init Api.Model.CollectiveSettings.empty , settingsModel = Comp.CollectiveSettingsForm.init Api.Model.CollectiveSettings.empty
, insights = Api.Model.ItemInsights.empty , insights = Api.Model.ItemInsights.empty
, submitResult = Nothing , submitResult = Nothing
} }
@ -46,7 +46,7 @@ type Msg
= SetTab Tab = SetTab Tab
| SourceMsg Comp.SourceManage.Msg | SourceMsg Comp.SourceManage.Msg
| UserMsg Comp.UserManage.Msg | UserMsg Comp.UserManage.Msg
| SettingsMsg Comp.Settings.Msg | SettingsFormMsg Comp.CollectiveSettingsForm.Msg
| Init | Init
| GetInsightsResp (Result Http.Error ItemInsights) | GetInsightsResp (Result Http.Error ItemInsights)
| CollectiveSettingsResp (Result Http.Error CollectiveSettings) | CollectiveSettingsResp (Result Http.Error CollectiveSettings)

View File

@ -2,7 +2,7 @@ module Page.CollectiveSettings.Update exposing (update)
import Api import Api
import Api.Model.BasicResult exposing (BasicResult) import Api.Model.BasicResult exposing (BasicResult)
import Comp.Settings import Comp.CollectiveSettingsForm
import Comp.SourceManage import Comp.SourceManage
import Comp.UserManage import Comp.UserManage
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
@ -45,10 +45,10 @@ update flags msg model =
in in
( { model | userModel = m2 }, Cmd.map UserMsg c2 ) ( { model | userModel = m2 }, Cmd.map UserMsg c2 )
SettingsMsg m -> SettingsFormMsg m ->
let let
( m2, c2, msett ) = ( m2, c2, msett ) =
Comp.Settings.update flags m model.settingsModel Comp.CollectiveSettingsForm.update flags m model.settingsModel
cmd = cmd =
case msett of case msett of
@ -58,7 +58,9 @@ update flags msg model =
Just sett -> Just sett ->
Api.setCollectiveSettings flags sett SubmitResp Api.setCollectiveSettings flags sett SubmitResp
in in
( { model | settingsModel = m2, submitResult = Nothing }, Cmd.batch [ cmd, Cmd.map SettingsMsg c2 ] ) ( { model | settingsModel = m2, submitResult = Nothing }
, Cmd.batch [ cmd, Cmd.map SettingsFormMsg c2 ]
)
Init -> Init ->
( { model | submitResult = Nothing } ( { model | submitResult = Nothing }
@ -75,7 +77,7 @@ update flags msg model =
( model, Cmd.none ) ( model, Cmd.none )
CollectiveSettingsResp (Ok data) -> CollectiveSettingsResp (Ok data) ->
( { model | settingsModel = Comp.Settings.init data }, Cmd.none ) ( { model | settingsModel = Comp.CollectiveSettingsForm.init data }, Cmd.none )
CollectiveSettingsResp (Err _) -> CollectiveSettingsResp (Err _) ->
( model, Cmd.none ) ( model, Cmd.none )

View File

@ -1,7 +1,7 @@
module Page.CollectiveSettings.View exposing (view) module Page.CollectiveSettings.View exposing (view)
import Api.Model.NameCount exposing (NameCount) import Api.Model.NameCount exposing (NameCount)
import Comp.Settings import Comp.CollectiveSettingsForm
import Comp.SourceManage import Comp.SourceManage
import Comp.UserManage import Comp.UserManage
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
@ -41,8 +41,8 @@ view flags model =
[ classActive (model.currentTab == Just SettingsTab) "link icon item" [ classActive (model.currentTab == Just SettingsTab) "link icon item"
, onClick (SetTab SettingsTab) , onClick (SetTab SettingsTab)
] ]
[ i [ class "language icon" ] [] [ i [ class "cog icon" ] []
, text "Document Language" , text "Settings"
] ]
, div , div
[ classActive (model.currentTab == Just UserTab) "link icon item" [ classActive (model.currentTab == Just UserTab) "link icon item"
@ -67,7 +67,7 @@ view flags model =
viewInsights model viewInsights model
Just SettingsTab -> Just SettingsTab ->
viewSettings model viewSettings flags model
Nothing -> Nothing ->
[] []
@ -176,42 +176,25 @@ viewUsers model =
] ]
viewSettings : Model -> List (Html Msg) viewSettings : Flags -> Model -> List (Html Msg)
viewSettings model = viewSettings flags model =
[ div [ class "ui grid" ] [ h2 [ class "ui header" ]
[ div [ class "row" ] [ i [ class "cog icon" ] []
[ div [ class "sixteen wide colum" ] , text "Settings"
[ h2 [ class "ui header" ] ]
[ i [ class "ui language icon" ] [] , div [ class "ui segment" ]
, div [ class "content" ] [ Html.map SettingsFormMsg (Comp.CollectiveSettingsForm.view flags model.settingsModel)
[ text "Document Language" ]
] , div
] [ classList
] [ ( "ui message", True )
] , ( "hidden", Util.Maybe.isEmpty model.submitResult )
, div [ class "row" ] , ( "success", Maybe.map .success model.submitResult |> Maybe.withDefault False )
[ div [ class "six wide column" ] , ( "error", Maybe.map .success model.submitResult |> Maybe.map not |> Maybe.withDefault False )
[ div [ class "ui basic segment" ]
[ text "The language of your documents. This helps text recognition (OCR) and text analysis."
]
]
]
, div [ class "row" ]
[ div [ class "six wide column" ]
[ Html.map SettingsMsg (Comp.Settings.view model.settingsModel)
, div
[ classList
[ ( "ui message", True )
, ( "hidden", Util.Maybe.isEmpty model.submitResult )
, ( "success", Maybe.map .success model.submitResult |> Maybe.withDefault False )
, ( "error", Maybe.map .success model.submitResult |> Maybe.map not |> Maybe.withDefault False )
]
]
[ Maybe.map .message model.submitResult
|> Maybe.withDefault ""
|> text
]
]
] ]
] ]
[ Maybe.map .message model.submitResult
|> Maybe.withDefault ""
|> text
]
] ]