From c7d587bea404a7dc31e13a7c9adbfcaf15410547 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 2 Oct 2021 15:16:02 +0200 Subject: [PATCH] Basic management of shares --- build.sbt | 15 +- .../scala/docspell/backend/BackendApp.scala | 3 + .../scala/docspell/backend/ops/OShare.scala | 116 ++++++ .../scala/docspell/common/Timestamp.scala | 3 + .../src/main/resources/docspell-openapi.yml | 178 ++++++++- .../restapi/codec/ItemQueryJson.scala | 24 ++ .../docspell/restserver/RestServer.scala | 1 + .../restserver/routes/ShareRoutes.scala | 105 ++++++ .../db/migration/h2/V1.27.1__item_share.sql | 13 + .../migration/mariadb/V1.27.1__item_share.sql | 13 + .../postgresql/V1.27.1__item_share.sql | 13 + .../src/main/scala/docspell/store/Store.scala | 2 + .../scala/docspell/store/impl/StoreImpl.scala | 5 + .../scala/docspell/store/records/RShare.scala | 62 +++- modules/webapp/src/main/elm/Api.elm | 59 +++ .../webapp/src/main/elm/Comp/DatePicker.elm | 2 +- .../webapp/src/main/elm/Comp/ShareForm.elm | 282 ++++++++++++++ .../webapp/src/main/elm/Comp/ShareManage.elm | 349 ++++++++++++++++++ .../webapp/src/main/elm/Comp/ShareTable.elm | 87 +++++ modules/webapp/src/main/elm/Data/Icons.elm | 20 +- .../src/main/elm/Messages/Comp/ShareForm.elm | 46 +++ .../main/elm/Messages/Comp/ShareManage.elm | 74 ++++ .../src/main/elm/Messages/Comp/ShareTable.elm | 42 +++ .../elm/Messages/Page/CollectiveSettings.elm | 7 + .../main/elm/Page/CollectiveSettings/Data.elm | 9 + .../elm/Page/CollectiveSettings/Update.elm | 11 + .../elm/Page/CollectiveSettings/View2.elm | 30 ++ 27 files changed, 1551 insertions(+), 20 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OShare.scala create mode 100644 modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql create mode 100644 modules/webapp/src/main/elm/Comp/ShareForm.elm create mode 100644 modules/webapp/src/main/elm/Comp/ShareManage.elm create mode 100644 modules/webapp/src/main/elm/Comp/ShareTable.elm create mode 100644 modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm create mode 100644 modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm create mode 100644 modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm diff --git a/build.sbt b/build.sbt index c4837e71..9e1b0777 100644 --- a/build.sbt +++ b/build.sbt @@ -260,6 +260,18 @@ val openapiScalaSettings = Seq( .copy(typeDef = TypeDef("AccountSource", Imports("docspell.common.AccountSource")) ) + case "itemquery" => + field => + field + .copy(typeDef = + TypeDef( + "ItemQuery", + Imports( + "docspell.query.ItemQuery", + "docspell.restapi.codec.ItemQueryJson._" + ) + ) + ) }) ) @@ -367,6 +379,7 @@ val store = project .settings(testSettingsMUnit) .settings( name := "docspell-store", + addCompilerPlugin(Dependencies.kindProjectorPlugin), libraryDependencies ++= Dependencies.doobie ++ Dependencies.binny ++ @@ -472,7 +485,7 @@ val restapi = project openapiSpec := (Compile / resourceDirectory).value / "docspell-openapi.yml", openapiStaticGen := OpenApiDocGenerator.Redoc ) - .dependsOn(common) + .dependsOn(common, query.jvm) val joexapi = project .in(file("modules/joexapi")) diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index fd12ec40..5a6fa482 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -48,6 +48,7 @@ trait BackendApp[F[_]] { def simpleSearch: OSimpleSearch[F] def clientSettings: OClientSettings[F] def totp: OTotp[F] + def share: OShare[F] } object BackendApp { @@ -85,6 +86,7 @@ object BackendApp { customFieldsImpl <- OCustomFields(store) simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) clientSettingsImpl <- OClientSettings(store) + shareImpl <- Resource.pure(OShare(store)) } yield new BackendApp[F] { val login = loginImpl val signup = signupImpl @@ -107,6 +109,7 @@ object BackendApp { val simpleSearch = simpleSearchImpl val clientSettings = clientSettingsImpl val totp = totpImpl + val share = shareImpl } def apply[F[_]: Async]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala new file mode 100644 index 00000000..68b86f11 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala @@ -0,0 +1,116 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.ops + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.backend.PasswordCrypt +import docspell.common._ +import docspell.query.ItemQuery +import docspell.store.Store +import docspell.store.records.RShare + +trait OShare[F[_]] { + + def findAll(collective: Ident): F[List[RShare]] + + def delete(id: Ident, collective: Ident): F[Boolean] + + def addNew(share: OShare.NewShare): F[OShare.ChangeResult] + + def findOne(id: Ident, collective: Ident): OptionT[F, RShare] + + def update( + id: Ident, + share: OShare.NewShare, + removePassword: Boolean + ): F[OShare.ChangeResult] +} + +object OShare { + + final case class NewShare( + cid: Ident, + name: Option[String], + query: ItemQuery, + enabled: Boolean, + password: Option[Password], + publishUntil: Timestamp + ) + + sealed trait ChangeResult + object ChangeResult { + final case class Success(id: Ident) extends ChangeResult + case object PublishUntilInPast extends ChangeResult + + def success(id: Ident): ChangeResult = Success(id) + def publishUntilInPast: ChangeResult = PublishUntilInPast + } + + def apply[F[_]: Async](store: Store[F]): OShare[F] = + new OShare[F] { + def findAll(collective: Ident): F[List[RShare]] = + store.transact(RShare.findAllByCollective(collective)) + + def delete(id: Ident, collective: Ident): F[Boolean] = + store.transact(RShare.deleteByIdAndCid(id, collective)).map(_ > 0) + + def addNew(share: NewShare): F[ChangeResult] = + for { + curTime <- Timestamp.current[F] + id <- Ident.randomId[F] + pass = share.password.map(PasswordCrypt.crypt) + record = RShare( + id, + share.cid, + share.name, + share.query, + share.enabled, + pass, + curTime, + share.publishUntil, + 0, + None + ) + res <- + if (share.publishUntil < curTime) ChangeResult.publishUntilInPast.pure[F] + else store.transact(RShare.insert(record)).map(_ => ChangeResult.success(id)) + } yield res + + def update( + id: Ident, + share: OShare.NewShare, + removePassword: Boolean + ): F[ChangeResult] = + for { + curTime <- Timestamp.current[F] + record = RShare( + id, + share.cid, + share.name, + share.query, + share.enabled, + share.password.map(PasswordCrypt.crypt), + Timestamp.Epoch, + share.publishUntil, + 0, + None + ) + res <- + if (share.publishUntil < curTime) ChangeResult.publishUntilInPast.pure[F] + else + store + .transact(RShare.updateData(record, removePassword)) + .map(_ => ChangeResult.success(id)) + } yield res + + def findOne(id: Ident, collective: Ident): OptionT[F, RShare] = + RShare.findOne(id, collective).mapK(store.transform) + } +} diff --git a/modules/common/src/main/scala/docspell/common/Timestamp.scala b/modules/common/src/main/scala/docspell/common/Timestamp.scala index d55e5fc4..b9aa104f 100644 --- a/modules/common/src/main/scala/docspell/common/Timestamp.scala +++ b/modules/common/src/main/scala/docspell/common/Timestamp.scala @@ -51,6 +51,9 @@ case class Timestamp(value: Instant) { def <(other: Timestamp): Boolean = this.value.isBefore(other.value) + + def >(other: Timestamp): Boolean = + this.value.isAfter(other.value) } object Timestamp { diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c5e95d29..f01cd129 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1711,6 +1711,96 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/share: + get: + operationId: "sec-share-get-all" + tags: [ Share ] + summary: Get a list of shares + description: | + Return a list of all shares for this collective. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ShareList" + post: + operationId: "sec-share-new" + tags: [ Share ] + summary: Create a new share. + description: | + Create a new share. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ShareData" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/IdResult" + /sec/share/{shareId}: + parameters: + - $ref: "#/components/parameters/shareId" + get: + operationId: "sec-share-get" + tags: [Share] + summary: Get details to a single share. + description: | + Given the id of a share, returns some details about it. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ShareDetail" + put: + operationId: "sec-share-update" + tags: [ Share ] + summary: Update an existing share. + description: | + Updates an existing share. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ShareData" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + operationId: "sec-share-delete-by-id" + tags: [ Share ] + summary: Delete a share. + description: | + Deletes a share + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/item/search: get: operationId: "sec-item-search-by-get" @@ -4096,6 +4186,83 @@ paths: components: schemas: + ShareData: + description: | + Editable data for a share. + required: + - query + - enabled + - publishUntil + properties: + name: + type: string + query: + type: string + format: itemquery + enabled: + type: boolean + password: + type: string + format: password + publishUntil: + type: integer + format: date-time + removePassword: + type: boolean + description: | + For an update request, this can control whether to delete + the password. Otherwise if the password is not set, it + will not be changed. When adding a new share, this has no + effect. + + ShareDetail: + description: | + Details for an existing share. + required: + - id + - query + - enabled + - publishAt + - publishUntil + - password + - views + properties: + id: + type: string + format: ident + query: + type: string + format: itemquery + name: + type: string + enabled: + type: boolean + publishAt: + type: integer + format: date-time + publishUntil: + type: integer + format: date-time + password: + type: boolean + views: + type: integer + format: int32 + lastAccess: + type: integer + format: date-time + + ShareList: + description: | + A list of shares. + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/ShareDetail" + DeleteUserData: description: | An excerpt of data that would be deleted when deleting the @@ -6121,8 +6288,8 @@ components: type: string IdResult: description: | - Some basic result of an operation with an ID as payload. If - success if `false` the id is not usable. + Some basic result of an operation with an ID as payload, if + success is true. If success is `false` the id is not usable. required: - success - message @@ -6257,6 +6424,13 @@ components: required: true schema: type: string + shareId: + name: shareId + in: path + description: An identifier for a share + required: true + schema: + type: string username: name: username in: path diff --git a/modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala b/modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala new file mode 100644 index 00000000..096c0ba5 --- /dev/null +++ b/modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restapi.codec + +import docspell.query.{ItemQuery, ItemQueryParser} + +import io.circe.{Decoder, Encoder} + +trait ItemQueryJson { + + implicit val itemQueryDecoder: Decoder[ItemQuery] = + Decoder.decodeString.emap(str => ItemQueryParser.parse(str).left.map(_.render)) + + implicit val itemQueryEncoder: Encoder[ItemQuery] = + Encoder.encodeString.contramap(q => + q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr)) + ) +} + +object ItemQueryJson extends ItemQueryJson diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 3cc244fb..6c620a0b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -94,6 +94,7 @@ object RestServer { "email/send" -> MailSendRoutes(restApp.backend, token), "email/settings" -> MailSettingsRoutes(restApp.backend, token), "email/sent" -> SentMailRoutes(restApp.backend, token), + "share" -> ShareRoutes.manage(restApp.backend, token), "usertask/notifydueitems" -> NotifyDueItemsRoutes(cfg, restApp.backend, token), "usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token), "calevent/check" -> CalEventCheckRoutes(), diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala new file mode 100644 index 00000000..060ef30c --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.backend.ops.OShare +import docspell.common.Ident +import docspell.restapi.model._ +import docspell.restserver.http4s.ResponseGenerator +import docspell.store.records.RShare + +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ShareRoutes { + + def manage[F[_]: Async](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root => + for { + all <- backend.share.findAll(user.account.collective) + res <- Ok(ShareList(all.map(mkShareDetail))) + } yield res + + case req @ POST -> Root => + for { + data <- req.as[ShareData] + share = mkNewShare(data, user) + res <- backend.share.addNew(share) + resp <- Ok(mkIdResult(res, "New share created.")) + } yield resp + + case GET -> Root / Ident(id) => + (for { + share <- backend.share.findOne(id, user.account.collective) + resp <- OptionT.liftF(Ok(mkShareDetail(share))) + } yield resp).getOrElseF(NotFound()) + + case req @ PUT -> Root / Ident(id) => + for { + data <- req.as[ShareData] + share = mkNewShare(data, user) + updated <- backend.share.update(id, share, data.removePassword.getOrElse(false)) + resp <- Ok(mkBasicResult(updated, "Share updated.")) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + del <- backend.share.delete(id, user.account.collective) + resp <- Ok(BasicResult(del, if (del) "Share deleted." else "Deleting failed.")) + } yield resp + } + } + + def mkNewShare(data: ShareData, user: AuthToken): OShare.NewShare = + OShare.NewShare( + user.account.collective, + data.name, + data.query, + data.enabled, + data.password, + data.publishUntil + ) + + def mkIdResult(r: OShare.ChangeResult, msg: => String): IdResult = + r match { + case OShare.ChangeResult.Success(id) => IdResult(true, msg, id) + case OShare.ChangeResult.PublishUntilInPast => + IdResult(false, "Until date must not be in the past", Ident.unsafe("")) + } + + def mkBasicResult(r: OShare.ChangeResult, msg: => String): BasicResult = + r match { + case OShare.ChangeResult.Success(_) => BasicResult(true, msg) + case OShare.ChangeResult.PublishUntilInPast => + BasicResult(false, "Until date must not be in the past") + } + + def mkShareDetail(r: RShare): ShareDetail = + ShareDetail( + r.id, + r.query, + r.name, + r.enabled, + r.publishAt, + r.publishUntil, + r.password.isDefined, + r.views, + r.lastAccess + ) +} diff --git a/modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql b/modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql new file mode 100644 index 00000000..7e252c14 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql @@ -0,0 +1,13 @@ +CREATE TABLE "item_share" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "name" varchar(254), + "query" varchar(2000) not null, + "enabled" boolean not null, + "pass" varchar(254), + "publish_at" timestamp not null, + "publish_until" timestamp not null, + "views" int not null, + "last_access" timestamp, + foreign key ("cid") references "collective"("cid") on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql new file mode 100644 index 00000000..fb74d283 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql @@ -0,0 +1,13 @@ +CREATE TABLE `item_share` ( + `id` varchar(254) not null primary key, + `cid` varchar(254) not null, + `name` varchar(254), + `query` varchar(2000) not null, + `enabled` boolean not null, + `pass` varchar(254), + `publish_at` timestamp not null, + `publish_until` timestamp not null, + `views` int not null, + `last_access` timestamp, + foreign key (`cid`) references `collective`(`cid`) on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql new file mode 100644 index 00000000..7e252c14 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql @@ -0,0 +1,13 @@ +CREATE TABLE "item_share" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "name" varchar(254), + "query" varchar(2000) not null, + "enabled" boolean not null, + "pass" varchar(254), + "publish_at" timestamp not null, + "publish_until" timestamp not null, + "views" int not null, + "last_access" timestamp, + foreign key ("cid") references "collective"("cid") on delete cascade +) diff --git a/modules/store/src/main/scala/docspell/store/Store.scala b/modules/store/src/main/scala/docspell/store/Store.scala index f68e353c..338b177a 100644 --- a/modules/store/src/main/scala/docspell/store/Store.scala +++ b/modules/store/src/main/scala/docspell/store/Store.scala @@ -9,6 +9,7 @@ package docspell.store import scala.concurrent.ExecutionContext import cats.effect._ +import cats.~> import fs2._ import docspell.store.file.FileStore @@ -19,6 +20,7 @@ import doobie._ import doobie.hikari.HikariTransactor trait Store[F[_]] { + def transform: ConnectionIO ~> F def transact[A](prg: ConnectionIO[A]): F[A] diff --git a/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala b/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala index f0f622bb..50c856b1 100644 --- a/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala +++ b/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala @@ -6,8 +6,10 @@ package docspell.store.impl +import cats.arrow.FunctionK import cats.effect.Async import cats.implicits._ +import cats.~> import docspell.store.file.FileStore import docspell.store.migrate.FlywayMigrate @@ -22,6 +24,9 @@ final class StoreImpl[F[_]: Async]( xa: Transactor[F] ) extends Store[F] { + def transform: ConnectionIO ~> F = + FunctionK.lift(transact) + def migrate: F[Int] = FlywayMigrate.run[F](jdbc).map(_.migrationsExecuted) diff --git a/modules/store/src/main/scala/docspell/store/records/RShare.scala b/modules/store/src/main/scala/docspell/store/records/RShare.scala index 72f67de7..af0b1e40 100644 --- a/modules/store/src/main/scala/docspell/store/records/RShare.scala +++ b/modules/store/src/main/scala/docspell/store/records/RShare.scala @@ -6,20 +6,25 @@ package docspell.store.records -import cats.data.NonEmptyList +import cats.data.{NonEmptyList, OptionT} import docspell.common._ import docspell.query.ItemQuery +import docspell.store.qb.DSL._ import docspell.store.qb._ +import doobie._ +import doobie.implicits._ + final case class RShare( id: Ident, cid: Ident, + name: Option[String], query: ItemQuery, enabled: Boolean, password: Option[Password], - publishedAt: Timestamp, - publishedUntil: Timestamp, + publishAt: Timestamp, + publishUntil: Timestamp, views: Int, lastAccess: Option[Timestamp] ) {} @@ -31,11 +36,12 @@ object RShare { val id = Column[Ident]("id", this) val cid = Column[Ident]("cid", this) + val name = Column[String]("name", this) val query = Column[ItemQuery]("query", this) val enabled = Column[Boolean]("enabled", this) - val password = Column[Password]("password", this) - val publishedAt = Column[Timestamp]("published_at", this) - val publishedUntil = Column[Timestamp]("published_until", this) + val password = Column[Password]("pass", this) + val publishedAt = Column[Timestamp]("publish_at", this) + val publishedUntil = Column[Timestamp]("publish_until", this) val views = Column[Int]("views", this) val lastAccess = Column[Timestamp]("last_access", this) @@ -43,6 +49,7 @@ object RShare { NonEmptyList.of( id, cid, + name, query, enabled, password, @@ -56,4 +63,47 @@ object RShare { val T: Table = Table(None) def as(alias: String): Table = Table(Some(alias)) + def insert(r: RShare): ConnectionIO[Int] = + DML.insert( + T, + T.all, + fr"${r.id},${r.cid},${r.name},${r.query},${r.enabled},${r.password},${r.publishAt},${r.publishUntil},${r.views},${r.lastAccess}" + ) + + def incAccess(id: Ident): ConnectionIO[Int] = + for { + curTime <- Timestamp.current[ConnectionIO] + n <- DML.update( + T, + T.id === id, + DML.set(T.views.increment(1), T.lastAccess.setTo(curTime)) + ) + } yield n + + def updateData(r: RShare, removePassword: Boolean): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id && T.cid === r.cid, + DML.set( + T.name.setTo(r.name), + T.query.setTo(r.query), + T.enabled.setTo(r.enabled), + T.publishedUntil.setTo(r.publishUntil) + ) ++ (if (r.password.isDefined || removePassword) + List(T.password.setTo(r.password)) + else Nil) + ) + + def findOne(id: Ident, cid: Ident): OptionT[ConnectionIO, RShare] = + OptionT( + Select(select(T.all), from(T), T.id === id && T.cid === cid).build + .query[RShare] + .option + ) + + def findAllByCollective(cid: Ident): ConnectionIO[List[RShare]] = + Select(select(T.all), from(T), T.cid === cid).build.query[RShare].to[List] + + def deleteByIdAndCid(id: Ident, cid: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id && T.cid === cid) } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 5ae8945d..a9619b1e 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -11,6 +11,7 @@ module Api exposing , addCorrOrg , addCorrPerson , addMember + , addShare , addTag , addTagsMultiple , attachmentPreviewURL @@ -40,6 +41,7 @@ module Api exposing , deleteOrg , deletePerson , deleteScanMailbox + , deleteShare , deleteSource , deleteTag , deleteUser @@ -72,6 +74,8 @@ module Api exposing , getPersonsLight , getScanMailbox , getSentMails + , getShare + , getShares , getSources , getTagCloud , getTags @@ -147,6 +151,7 @@ module Api exposing , unconfirmMultiple , updateNotifyDueItems , updateScanMailbox + , updateShare , upload , uploadAmend , uploadSingle @@ -215,6 +220,9 @@ import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList) import Api.Model.SearchStats exposing (SearchStats) import Api.Model.SecondFactor exposing (SecondFactor) import Api.Model.SentMails exposing (SentMails) +import Api.Model.ShareData exposing (ShareData) +import Api.Model.ShareDetail exposing (ShareDetail) +import Api.Model.ShareList exposing (ShareList) import Api.Model.SimpleMail exposing (SimpleMail) import Api.Model.SourceAndTags exposing (SourceAndTags) import Api.Model.SourceList exposing (SourceList) @@ -2206,6 +2214,57 @@ disableOtp flags otp receive = +--- Share + + +getShares : Flags -> (Result Http.Error ShareList -> msg) -> Cmd msg +getShares flags receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/share" + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.ShareList.decoder + } + + +getShare : Flags -> String -> (Result Http.Error ShareDetail -> msg) -> Cmd msg +getShare flags id receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.ShareDetail.decoder + } + + +addShare : Flags -> ShareData -> (Result Http.Error IdResult -> msg) -> Cmd msg +addShare flags share receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/share" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ShareData.encode share) + , expect = Http.expectJson receive Api.Model.IdResult.decoder + } + + +updateShare : Flags -> String -> ShareData -> (Result Http.Error BasicResult -> msg) -> Cmd msg +updateShare flags id share receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ShareData.encode share) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +deleteShare : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteShare flags id receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/DatePicker.elm b/modules/webapp/src/main/elm/Comp/DatePicker.elm index 3804c296..e0383651 100644 --- a/modules/webapp/src/main/elm/Comp/DatePicker.elm +++ b/modules/webapp/src/main/elm/Comp/DatePicker.elm @@ -37,7 +37,7 @@ init = emptyModel : DatePicker emptyModel = - DatePicker.initFromDate (Date.fromCalendarDate 2019 Aug 21) + DatePicker.initFromDate (Date.fromCalendarDate 2021 Oct 31) defaultSettings : Settings diff --git a/modules/webapp/src/main/elm/Comp/ShareForm.elm b/modules/webapp/src/main/elm/Comp/ShareForm.elm new file mode 100644 index 00000000..4f2d39cf --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareForm.elm @@ -0,0 +1,282 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareForm exposing (Model, Msg, getShare, init, setShare, update, view) + +import Api.Model.ShareData exposing (ShareData) +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Basic as B +import Comp.DatePicker +import Comp.PasswordInput +import Data.Flags exposing (Flags) +import DatePicker exposing (DatePicker) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onCheck, onInput) +import Messages.Comp.ShareForm exposing (Texts) +import Styles as S +import Util.Maybe + + +type alias Model = + { share : ShareDetail + , name : Maybe String + , query : String + , enabled : Bool + , passwordModel : Comp.PasswordInput.Model + , password : Maybe String + , passwordSet : Bool + , clearPassword : Bool + , untilModel : DatePicker + , untilDate : Maybe Int + } + + +init : ( Model, Cmd Msg ) +init = + let + ( dp, dpc ) = + Comp.DatePicker.init + in + ( { share = Api.Model.ShareDetail.empty + , name = Nothing + , query = "" + , enabled = False + , passwordModel = Comp.PasswordInput.init + , password = Nothing + , passwordSet = False + , clearPassword = False + , untilModel = dp + , untilDate = Nothing + } + , Cmd.map UntilDateMsg dpc + ) + + +isValid : Model -> Bool +isValid model = + model.query /= "" && model.untilDate /= Nothing + + +type Msg + = SetName String + | SetQuery String + | SetShare ShareDetail + | ToggleEnabled + | ToggleClearPassword + | PasswordMsg Comp.PasswordInput.Msg + | UntilDateMsg Comp.DatePicker.Msg + + +setShare : ShareDetail -> Msg +setShare share = + SetShare share + + +getShare : Model -> Maybe ( String, ShareData ) +getShare model = + if isValid model then + Just + ( model.share.id + , { name = model.name + , query = model.query + , enabled = model.enabled + , password = model.password + , removePassword = + if model.share.id == "" then + Nothing + + else + Just model.clearPassword + , publishUntil = Maybe.withDefault 0 model.untilDate + } + ) + + else + Nothing + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update _ msg model = + case msg of + SetShare s -> + ( { model + | share = s + , name = s.name + , query = s.query + , enabled = s.enabled + , password = Nothing + , passwordSet = s.password + , clearPassword = False + , untilDate = + if s.publishUntil > 0 then + Just s.publishUntil + + else + Nothing + } + , Cmd.none + ) + + SetName n -> + ( { model | name = Util.Maybe.fromString n }, Cmd.none ) + + SetQuery n -> + ( { model | query = n }, Cmd.none ) + + ToggleEnabled -> + ( { model | enabled = not model.enabled }, Cmd.none ) + + ToggleClearPassword -> + ( { model | clearPassword = not model.clearPassword }, Cmd.none ) + + PasswordMsg lm -> + let + ( pm, pw ) = + Comp.PasswordInput.update lm model.passwordModel + in + ( { model + | passwordModel = pm + , password = pw + } + , Cmd.none + ) + + UntilDateMsg lm -> + let + ( dp, event ) = + Comp.DatePicker.updateDefault lm model.untilModel + + nextDate = + case event of + DatePicker.Picked date -> + Just (Comp.DatePicker.endOfDay date) + + _ -> + Nothing + in + ( { model | untilModel = dp, untilDate = nextDate } + , Cmd.none + ) + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + div + [ class "flex flex-col" ] + [ div [ class "mb-4" ] + [ label + [ for "sharename" + , class S.inputLabel + ] + [ text texts.basics.name + ] + , input + [ type_ "text" + , onInput SetName + , placeholder texts.basics.name + , value <| Maybe.withDefault "" model.name + , id "sharename" + , class S.textInput + ] + [] + ] + , div [ class "mb-4" ] + [ label + [ for "sharequery" + , class S.inputLabel + ] + [ text texts.queryLabel + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetQuery + , placeholder texts.queryLabel + , value model.query + , id "sharequery" + , class S.textInput + , classList + [ ( S.inputErrorBorder + , not (isValid model) + ) + ] + ] + [] + ] + , div [ class "mb-4" ] + [ label + [ class "inline-flex items-center" + , for "source-enabled" + ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleEnabled) + , checked model.enabled + , class S.checkboxInput + , id "source-enabled" + ] + [] + , span [ class "ml-2" ] + [ text texts.enabled + ] + ] + ] + , div [ class "mb-4" ] + [ label + [ class S.inputLabel + ] + [ text texts.password + ] + , Html.map PasswordMsg + (Comp.PasswordInput.view2 + { placeholder = texts.password } + model.password + False + model.passwordModel + ) + , div + [ class "mb-2" + , classList [ ( "hidden", not model.passwordSet ) ] + ] + [ label + [ class "inline-flex items-center" + , for "clear-password" + ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleClearPassword) + , checked model.clearPassword + , class S.checkboxInput + , id "clear-password" + ] + [] + , span [ class "ml-2" ] + [ text texts.clearPassword + ] + ] + ] + ] + , div [ class "mb-2 max-w-sm" ] + [ label [ class S.inputLabel ] + [ text texts.publishUntil + , B.inputRequired + ] + , div [ class "relative" ] + [ Html.map UntilDateMsg + (Comp.DatePicker.viewTimeDefault + model.untilDate + model.untilModel + ) + , i [ class S.dateInputIcon, class "fa fa-calendar" ] [] + ] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ShareManage.elm b/modules/webapp/src/main/elm/Comp/ShareManage.elm new file mode 100644 index 00000000..4fc431af --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareManage.elm @@ -0,0 +1,349 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareManage exposing (Model, Msg, init, loadShares, update, view) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.IdResult exposing (IdResult) +import Api.Model.ShareDetail exposing (ShareDetail) +import Api.Model.ShareList exposing (ShareList) +import Comp.Basic as B +import Comp.ItemDetail.Model exposing (Msg(..)) +import Comp.MenuBar as MB +import Comp.ShareForm +import Comp.ShareTable +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.ShareManage exposing (Texts) +import Styles as S + + +type FormError + = FormErrorNone + | FormErrorHttp Http.Error + | FormErrorInvalid + | FormErrorSubmit String + + +type ViewMode + = Table + | Form + + +type DeleteConfirm + = DeleteConfirmOff + | DeleteConfirmOn + + +type alias Model = + { viewMode : ViewMode + , shares : List ShareDetail + , formModel : Comp.ShareForm.Model + , loading : Bool + , formError : FormError + , deleteConfirm : DeleteConfirm + } + + +init : ( Model, Cmd Msg ) +init = + let + ( fm, fc ) = + Comp.ShareForm.init + in + ( { viewMode = Table + , shares = [] + , formModel = fm + , loading = False + , formError = FormErrorNone + , deleteConfirm = DeleteConfirmOff + } + , Cmd.map FormMsg fc + ) + + +type Msg + = LoadShares + | TableMsg Comp.ShareTable.Msg + | FormMsg Comp.ShareForm.Msg + | InitNewShare + | SetViewMode ViewMode + | Submit + | RequestDelete + | CancelDelete + | DeleteShareNow String + | LoadSharesResp (Result Http.Error ShareList) + | AddShareResp (Result Http.Error IdResult) + | UpdateShareResp (Result Http.Error BasicResult) + | GetShareResp (Result Http.Error ShareDetail) + | DeleteShareResp (Result Http.Error BasicResult) + + +loadShares : Msg +loadShares = + LoadShares + + + +--- update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + InitNewShare -> + let + nm = + { model | viewMode = Form, formError = FormErrorNone } + + share = + Api.Model.ShareDetail.empty + in + update flags (FormMsg (Comp.ShareForm.setShare share)) nm + + SetViewMode vm -> + ( { model | viewMode = vm, formError = FormErrorNone } + , if vm == Table then + Api.getShares flags LoadSharesResp + + else + Cmd.none + ) + + FormMsg lm -> + let + ( fm, fc ) = + Comp.ShareForm.update flags lm model.formModel + in + ( { model | formModel = fm }, Cmd.map FormMsg fc ) + + TableMsg lm -> + let + action = + Comp.ShareTable.update lm + + nextModel = + { model | viewMode = Form, formError = FormErrorNone } + in + case action of + Comp.ShareTable.Edit share -> + update flags (FormMsg <| Comp.ShareForm.setShare share) nextModel + + RequestDelete -> + ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none ) + + CancelDelete -> + ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none ) + + DeleteShareNow id -> + ( { model | deleteConfirm = DeleteConfirmOff, loading = True } + , Api.deleteShare flags id DeleteShareResp + ) + + LoadShares -> + ( { model | loading = True }, Api.getShares flags LoadSharesResp ) + + LoadSharesResp (Ok list) -> + ( { model | loading = False, shares = list.items, formError = FormErrorNone }, Cmd.none ) + + LoadSharesResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none ) + + Submit -> + case Comp.ShareForm.getShare model.formModel of + Just ( id, data ) -> + if id == "" then + ( { model | loading = True }, Api.addShare flags data AddShareResp ) + + else + ( { model | loading = True }, Api.updateShare flags id data UpdateShareResp ) + + Nothing -> + ( { model | formError = FormErrorInvalid }, Cmd.none ) + + AddShareResp (Ok res) -> + if res.success then + ( model, Api.getShare flags res.id GetShareResp ) + + else + ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none ) + + AddShareResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none ) + + UpdateShareResp (Ok res) -> + if res.success then + ( model, Api.getShare flags model.formModel.share.id GetShareResp ) + + else + ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none ) + + UpdateShareResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none ) + + GetShareResp (Ok share) -> + let + nextModel = + { model | formError = FormErrorNone, loading = False } + in + update flags (FormMsg <| Comp.ShareForm.setShare share) nextModel + + GetShareResp (Err err) -> + ( { model | formError = FormErrorHttp err }, Cmd.none ) + + DeleteShareResp (Ok res) -> + if res.success then + update flags (SetViewMode Table) { model | loading = False } + + else + ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none ) + + DeleteShareResp (Err err) -> + ( { model | formError = FormErrorHttp err, loading = False }, Cmd.none ) + + + +--- view + + +view : Texts -> Flags -> Model -> Html Msg +view texts _ model = + if model.viewMode == Table then + viewTable texts model + + else + viewForm texts model + + +viewTable : Texts -> Model -> Html Msg +viewTable texts model = + div [ class "flex flex-col" ] + [ MB.view + { start = + [] + , end = + [ MB.PrimaryButton + { tagger = InitNewShare + , title = texts.createNewShare + , icon = Just "fa fa-plus" + , label = texts.newShare + } + ] + , rootClasses = "mb-4" + } + , Html.map TableMsg (Comp.ShareTable.view texts.shareTable model.shares) + , B.loadingDimmer + { label = "" + , active = model.loading + } + ] + + +viewForm : Texts -> Model -> Html Msg +viewForm texts model = + let + newShare = + model.formModel.share.id == "" + in + Html.form [ class "relative" ] + [ if newShare then + h1 [ class S.header2 ] + [ text texts.createNewShare + ] + + else + h1 [ class S.header2 ] + [ text <| Maybe.withDefault texts.noName model.formModel.share.name + , div [ class "opacity-50 text-sm" ] + [ text "Id: " + , text model.formModel.share.id + ] + ] + , MB.view + { start = + [ MB.PrimaryButton + { tagger = Submit + , title = "Submit this form" + , icon = Just "fa fa-save" + , label = texts.basics.submit + } + , MB.SecondaryButton + { tagger = SetViewMode Table + , title = texts.basics.backToList + , icon = Just "fa fa-arrow-left" + , label = texts.basics.cancel + } + ] + , end = + if not newShare then + [ MB.DeleteButton + { tagger = RequestDelete + , title = texts.deleteThisShare + , icon = Just "fa fa-trash" + , label = texts.basics.delete + } + ] + + else + [] + , rootClasses = "mb-4" + } + , div + [ classList + [ ( "hidden", model.formError == FormErrorNone ) + ] + , class "my-2" + , class S.errorMessage + ] + [ case model.formError of + FormErrorNone -> + text "" + + FormErrorHttp err -> + text (texts.httpError err) + + FormErrorInvalid -> + text texts.correctFormErrors + + FormErrorSubmit m -> + text m + ] + , Html.map FormMsg (Comp.ShareForm.view texts.shareForm model.formModel) + , B.loadingDimmer + { active = model.loading + , label = texts.basics.loading + } + , B.contentDimmer + (model.deleteConfirm == DeleteConfirmOn) + (div [ class "flex flex-col" ] + [ div [ class "text-lg" ] + [ i [ class "fa fa-info-circle mr-2" ] [] + , text texts.reallyDeleteShare + ] + , div [ class "mt-4 flex flex-row items-center" ] + [ B.deleteButton + { label = texts.basics.yes + , icon = "fa fa-check" + , disabled = False + , handler = onClick (DeleteShareNow model.formModel.share.id) + , attrs = [ href "#" ] + } + , B.secondaryButton + { label = texts.basics.no + , icon = "fa fa-times" + , disabled = False + , handler = onClick CancelDelete + , attrs = [ href "#", class "ml-2" ] + } + ] + ] + ) + ] diff --git a/modules/webapp/src/main/elm/Comp/ShareTable.elm b/modules/webapp/src/main/elm/Comp/ShareTable.elm new file mode 100644 index 00000000..940f64c5 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareTable.elm @@ -0,0 +1,87 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareTable exposing + ( Msg(..) + , SelectAction(..) + , update + , view + ) + +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Basic as B +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Comp.ShareTable exposing (Texts) +import Styles as S +import Util.Html +import Util.String + + +type Msg + = Select ShareDetail + + +type SelectAction + = Edit ShareDetail + + +update : Msg -> SelectAction +update msg = + case msg of + Select share -> + Edit share + + + +--- View + + +view : Texts -> List ShareDetail -> Html Msg +view texts shares = + table [ class S.tableMain ] + [ thead [] + [ tr [] + [ th [ class "" ] [] + , th [ class "text-left" ] + [ text texts.basics.id + ] + , th [ class "text-left" ] + [ text texts.basics.name + ] + , th [ class "text-center" ] + [ text texts.enabled + ] + , th [ class "text-center" ] + [ text texts.publishUntil + ] + ] + ] + , tbody [] + (List.map (renderShareLine texts) shares) + ] + + +renderShareLine : Texts -> ShareDetail -> Html Msg +renderShareLine texts share = + tr + [ class S.tableRow + ] + [ B.editLinkTableCell texts.basics.edit (Select share) + , td [ class "text-left py-4 md:py-2" ] + [ text (Util.String.ellipsis 8 share.id) + ] + , td [ class "text-left py-4 md:py-2" ] + [ text (Maybe.withDefault "-" share.name) + ] + , td [ class "w-px px-2 text-center" ] + [ Util.Html.checkbox2 share.enabled + ] + , td [ class "hidden sm:table-cell text-center" ] + [ texts.formatDateTime share.publishUntil |> text + ] + ] diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index f2455c8d..76a5f360 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -58,11 +58,11 @@ module Data.Icons exposing , personIcon2 , search , searchIcon + , share + , shareIcon , showQr , showQrIcon - , source , source2 - , sourceIcon , sourceIcon2 , tag , tag2 @@ -79,9 +79,14 @@ import Html exposing (Html, i) import Html.Attributes exposing (class) -source : String -source = - "upload icon" +share : String +share = + "fa fa-share-alt" + + +shareIcon : String -> Html msg +shareIcon classes = + i [ class (classes ++ " " ++ share) ] [] source2 : String @@ -89,11 +94,6 @@ source2 = "fa fa-upload" -sourceIcon : String -> Html msg -sourceIcon classes = - i [ class (source ++ " " ++ classes) ] [] - - sourceIcon2 : String -> Html msg sourceIcon2 classes = i [ class (source2 ++ " " ++ classes) ] [] diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm new file mode 100644 index 00000000..44a9bcb5 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm @@ -0,0 +1,46 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , queryLabel : String + , enabled : String + , password : String + , publishUntil : String + , clearPassword : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , queryLabel = "Query" + , enabled = "Enabled" + , password = "Password" + , publishUntil = "Publish Until" + , clearPassword = "Remove password" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , queryLabel = "Abfrage" + , enabled = "Aktiv" + , password = "Passwort" + , publishUntil = "Publiziert bis" + , clearPassword = "Passwort entfernen" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm new file mode 100644 index 00000000..66b27e6e --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm @@ -0,0 +1,74 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareManage exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.ShareForm +import Messages.Comp.ShareTable + + +type alias Texts = + { basics : Messages.Basics.Texts + , shareTable : Messages.Comp.ShareTable.Texts + , shareForm : Messages.Comp.ShareForm.Texts + , httpError : Http.Error -> String + , newShare : String + , copyToClipboard : String + , openInNewTab : String + , publicUrl : String + , reallyDeleteShare : String + , createNewShare : String + , deleteThisShare : String + , errorGeneratingQR : String + , correctFormErrors : String + , noName : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , httpError = Messages.Comp.HttpError.gb + , shareTable = Messages.Comp.ShareTable.gb + , shareForm = Messages.Comp.ShareForm.gb + , newShare = "New share" + , copyToClipboard = "Copy to clipboard" + , openInNewTab = "Open in new tab/window" + , publicUrl = "Public URL" + , reallyDeleteShare = "Really delete this share?" + , createNewShare = "Create new share" + , deleteThisShare = "Delete this share" + , errorGeneratingQR = "Error generating QR Code" + , correctFormErrors = "Please correct the errors in the form." + , noName = "No Name" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , shareTable = Messages.Comp.ShareTable.de + , shareForm = Messages.Comp.ShareForm.de + , httpError = Messages.Comp.HttpError.de + , newShare = "Neue Freigabe" + , copyToClipboard = "In die Zwischenablage kopieren" + , openInNewTab = "Im neuen Tab/Fenster öffnen" + , publicUrl = "Öffentliche URL" + , reallyDeleteShare = "Diese Freigabe wirklich entfernen?" + , createNewShare = "Neue Freigabe erstellen" + , deleteThisShare = "Freigabe löschen" + , errorGeneratingQR = "Fehler beim Generieren des QR-Code" + , correctFormErrors = "Bitte korrigiere die Fehler im Formular." + , noName = "Ohne Name" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm new file mode 100644 index 00000000..7b68fcc4 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm @@ -0,0 +1,42 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareTable exposing + ( Texts + , de + , gb + ) + +import Messages.Basics +import Messages.DateFormat as DF +import Messages.UiLanguage + + +type alias Texts = + { basics : Messages.Basics.Texts + , formatDateTime : Int -> String + , enabled : String + , publishUntil : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , formatDateTime = DF.formatDateTimeLong Messages.UiLanguage.English + , enabled = "Enabled" + , publishUntil = "Publish Until" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , formatDateTime = DF.formatDateTimeLong Messages.UiLanguage.German + , enabled = "Aktiv" + , publishUntil = "Publiziert bis" + } diff --git a/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm b/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm index dc36ab98..4ca75f93 100644 --- a/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm +++ b/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm @@ -15,6 +15,7 @@ import Http import Messages.Basics import Messages.Comp.CollectiveSettingsForm import Messages.Comp.HttpError +import Messages.Comp.ShareManage import Messages.Comp.SourceManage import Messages.Comp.UserManage @@ -24,12 +25,14 @@ type alias Texts = , userManage : Messages.Comp.UserManage.Texts , collectiveSettingsForm : Messages.Comp.CollectiveSettingsForm.Texts , sourceManage : Messages.Comp.SourceManage.Texts + , shareManage : Messages.Comp.ShareManage.Texts , httpError : Http.Error -> String , collectiveSettings : String , insights : String , sources : String , settings : String , users : String + , shares : String , user : String , collective : String , size : String @@ -44,12 +47,14 @@ gb = , userManage = Messages.Comp.UserManage.gb , collectiveSettingsForm = Messages.Comp.CollectiveSettingsForm.gb , sourceManage = Messages.Comp.SourceManage.gb + , shareManage = Messages.Comp.ShareManage.gb , httpError = Messages.Comp.HttpError.gb , collectiveSettings = "Collective Settings" , insights = "Insights" , sources = "Sources" , settings = "Settings" , users = "Users" + , shares = "Shares" , user = "User" , collective = "Collective" , size = "Size" @@ -64,12 +69,14 @@ de = , userManage = Messages.Comp.UserManage.de , collectiveSettingsForm = Messages.Comp.CollectiveSettingsForm.de , sourceManage = Messages.Comp.SourceManage.de + , shareManage = Messages.Comp.ShareManage.de , httpError = Messages.Comp.HttpError.de , collectiveSettings = "Kollektiveinstellungen" , insights = "Statistiken" , sources = "Quellen" , settings = "Einstellungen" , users = "Benutzer" + , shares = "Freigaben" , user = "Benutzer" , collective = "Kollektiv" , size = "Größe" diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm index d24b9494..e940ade4 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm @@ -17,6 +17,7 @@ import Api.Model.BasicResult exposing (BasicResult) import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Api.Model.ItemInsights exposing (ItemInsights) import Comp.CollectiveSettingsForm +import Comp.ShareManage import Comp.SourceManage import Comp.UserManage import Data.Flags exposing (Flags) @@ -28,6 +29,7 @@ type alias Model = , sourceModel : Comp.SourceManage.Model , userModel : Comp.UserManage.Model , settingsModel : Comp.CollectiveSettingsForm.Model + , shareModel : Comp.ShareManage.Model , insights : ItemInsights , formState : FormState } @@ -48,10 +50,14 @@ init flags = ( cm, cc ) = Comp.CollectiveSettingsForm.init flags Api.Model.CollectiveSettings.empty + + ( shm, shc ) = + Comp.ShareManage.init in ( { currentTab = Just InsightsTab , sourceModel = sm , userModel = Comp.UserManage.emptyModel + , shareModel = shm , settingsModel = cm , insights = Api.Model.ItemInsights.empty , formState = InitialState @@ -59,6 +65,7 @@ init flags = , Cmd.batch [ Cmd.map SourceMsg sc , Cmd.map SettingsFormMsg cc + , Cmd.map ShareMsg shc ] ) @@ -68,6 +75,7 @@ type Tab | UserTab | InsightsTab | SettingsTab + | ShareTab type Msg @@ -79,3 +87,4 @@ type Msg | GetInsightsResp (Result Http.Error ItemInsights) | CollectiveSettingsResp (Result Http.Error CollectiveSettings) | SubmitResp (Result Http.Error BasicResult) + | ShareMsg Comp.ShareManage.Msg diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm index b3b55b88..1e711acd 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm @@ -9,6 +9,7 @@ module Page.CollectiveSettings.Update exposing (update) import Api import Comp.CollectiveSettingsForm +import Comp.ShareManage import Comp.SourceManage import Comp.UserManage import Data.Flags exposing (Flags) @@ -36,6 +37,9 @@ update flags msg model = SettingsTab -> update flags Init m + ShareTab -> + update flags (ShareMsg Comp.ShareManage.loadShares) m + SourceMsg m -> let ( m2, c2 ) = @@ -43,6 +47,13 @@ update flags msg model = in ( { model | sourceModel = m2 }, Cmd.map SourceMsg c2 ) + ShareMsg lm -> + let + ( sm, sc ) = + Comp.ShareManage.update flags lm model.shareModel + in + ( { model | shareModel = sm }, Cmd.map ShareMsg sc ) + UserMsg m -> let ( m2, c2 ) = diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm index 4454e30f..7c31f7ff 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm @@ -10,6 +10,7 @@ module Page.CollectiveSettings.View2 exposing (viewContent, viewSidebar) import Api.Model.TagCount exposing (TagCount) import Comp.Basic as B import Comp.CollectiveSettingsForm +import Comp.ShareManage import Comp.SourceManage import Comp.UserManage import Data.Flags exposing (Flags) @@ -60,6 +61,17 @@ viewSidebar texts visible _ _ model = [ class "ml-3" ] [ text texts.sources ] ] + , a + [ href "#" + , onClick (SetTab ShareTab) + , class S.sidebarLink + , menuEntryActive model ShareTab + ] + [ Icons.shareIcon "" + , span + [ class "ml-3" ] + [ text texts.shares ] + ] , a [ href "#" , onClick (SetTab SettingsTab) @@ -105,6 +117,9 @@ viewContent texts flags settings model = Just SourceTab -> viewSources texts flags settings model + Just ShareTab -> + viewShares texts flags model + Nothing -> [] ) @@ -230,6 +245,21 @@ viewSources texts flags settings model = ] +viewShares : Texts -> Flags -> Model -> List (Html Msg) +viewShares texts flags model = + [ h1 + [ class S.header1 + , class "inline-flex items-center" + ] + [ Icons.shareIcon "" + , div [ class "ml-3" ] + [ text texts.shares + ] + ] + , Html.map ShareMsg (Comp.ShareManage.view texts.shareManage flags model.shareModel) + ] + + viewUsers : Texts -> UiSettings -> Model -> List (Html Msg) viewUsers texts settings model = [ h1