From e15697267a2217e8dbe4730d50566f2566f89540 Mon Sep 17 00:00:00 2001
From: Martin Weise <martin.weise@tuwien.ac.at>
Date: Thu, 18 May 2023 17:49:01 +0200
Subject: [PATCH] WIP

---
 dbrepo-authentication-service/Dockerfile      |   3 +-
 .../dbrepo-realm.json                         | 103 ++++++--
 .../generate-keystore.sh                      |   2 +
 dbrepo-authentication-service/server.keystore | Bin 0 -> 2776 bytes
 .../maintenance/BannerMessageBriefDto.java    |  33 +++
 .../maintenance/BannerMessageCreateDto.java   |  44 ++++
 .../api/maintenance/BannerMessageDto.java     |  47 ++++
 .../api/maintenance/BannerMessageTypeDto.java |  28 +++
 .../maintenance/BannerMessageUpdateDto.java   |  44 ++++
 .../entities/maintenance/BannerMessage.java   |  43 ++++
 .../maintenance/BannerMessageType.java        |  13 +
 dbrepo-metadata-db/setup-schema.sql           |  12 +
 dbrepo-metadata-service/pom.xml               |   6 +
 .../at/tuwien/config/WebSecurityConfig.java   |   1 -
 .../at/tuwien/config/WebSecurityConfig.java   |   1 -
 dbrepo-ui/api/metadata.service.js             | 104 ++++++++
 dbrepo-ui/components/DBToolbar.vue            |   5 +-
 dbrepo-ui/components/DatabaseList.vue         |   5 +-
 dbrepo-ui/components/QueryList.vue            |  28 +--
 dbrepo-ui/components/TableToolbar.vue         |   8 +-
 dbrepo-ui/components/UserToolbar.vue          |  26 ++
 .../dialogs/EditMaintenanceMessage.vue        | 228 ++++++++++++++++++
 dbrepo-ui/layouts/default.vue                 |  47 ++--
 dbrepo-ui/nuxt.config.js                      |   3 +-
 .../_database_id/query/_query_id/index.vue    |   6 +-
 .../_database_id/table/_table_id/data.vue     |  32 +--
 .../_database_id/table/_table_id/info.vue     |  13 -
 dbrepo-ui/pages/user/authentication.vue       |   1 -
 dbrepo-ui/pages/user/developer.vue            | 133 ++++++++++
 dbrepo-ui/store/index.js                      |  16 +-
 dbrepo-ui/utils/index.js                      |  22 +-
 .../tuwien/endpoint/MaintenanceEndpoint.java  | 161 +++++++++++++
 .../at/tuwien/config/WebSecurityConfig.java   |   1 +
 .../BannerMessageNotFoundException.java       |  20 ++
 .../at/tuwien/mapper/BannerMessageMapper.java |  24 ++
 .../jpa/BannerMessageRepository.java          |  14 ++
 .../tuwien/service/BannerMessageService.java  |  22 ++
 .../impl/BannerMessageServiceImpl.java        |  78 ++++++
 dbrepo.conf                                   |   9 +
 39 files changed, 1277 insertions(+), 109 deletions(-)
 create mode 100644 dbrepo-authentication-service/generate-keystore.sh
 create mode 100644 dbrepo-authentication-service/server.keystore
 create mode 100644 dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageBriefDto.java
 create mode 100644 dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageCreateDto.java
 create mode 100644 dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageDto.java
 create mode 100644 dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageTypeDto.java
 create mode 100644 dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageUpdateDto.java
 create mode 100644 dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessage.java
 create mode 100644 dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessageType.java
 create mode 100644 dbrepo-ui/api/metadata.service.js
 create mode 100644 dbrepo-ui/components/dialogs/EditMaintenanceMessage.vue
 create mode 100644 dbrepo-ui/pages/user/developer.vue
 create mode 100644 dbrepo-user-service/rest-service/src/main/java/at/tuwien/endpoint/MaintenanceEndpoint.java
 create mode 100644 dbrepo-user-service/services/src/main/java/at/tuwien/exception/BannerMessageNotFoundException.java
 create mode 100644 dbrepo-user-service/services/src/main/java/at/tuwien/mapper/BannerMessageMapper.java
 create mode 100644 dbrepo-user-service/services/src/main/java/at/tuwien/repository/jpa/BannerMessageRepository.java
 create mode 100644 dbrepo-user-service/services/src/main/java/at/tuwien/service/BannerMessageService.java
 create mode 100644 dbrepo-user-service/services/src/main/java/at/tuwien/service/impl/BannerMessageServiceImpl.java

diff --git a/dbrepo-authentication-service/Dockerfile b/dbrepo-authentication-service/Dockerfile
index d5aae51744..a44ea5df15 100644
--- a/dbrepo-authentication-service/Dockerfile
+++ b/dbrepo-authentication-service/Dockerfile
@@ -5,13 +5,14 @@ MAINTAINER Martin Weise <martin.weise@tuwien.ac.at>
 # Enable health and metrics support
 ENV KC_HEALTH_ENABLED=true
 ENV KC_METRICS_ENABLED=true
-ENV KC_FEATURES=update-email
 
 # Configure a database vendor
 ENV KC_DB=mariadb
 
 WORKDIR /opt/keycloak
 
+COPY ./server.keystore ./conf/server.keystore
+
 RUN /opt/keycloak/bin/kc.sh build
 
 ###### SECOND STAGE ######
diff --git a/dbrepo-authentication-service/dbrepo-realm.json b/dbrepo-authentication-service/dbrepo-realm.json
index e2fc0ac590..040ad7b261 100644
--- a/dbrepo-authentication-service/dbrepo-realm.json
+++ b/dbrepo-authentication-service/dbrepo-realm.json
@@ -55,6 +55,17 @@
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
       "attributes" : { }
+    }, {
+      "id" : "9bb4a8dc-28e0-4645-b62f-cc94425f0cb0",
+      "name" : "default-maintenance-handling",
+      "description" : "${default-maintenance-handling}",
+      "composite" : true,
+      "composites" : {
+        "realm" : [ "create-maintenance-message", "find-maintenance-message", "update-maintenance-message", "delete-maintenance-message", "list-maintenance-messages" ]
+      },
+      "clientRole" : false,
+      "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
+      "attributes" : { }
     }, {
       "id" : "5136d7a3-e3f0-4585-bacd-15cb8a56095c",
       "name" : "escalated-container-handling",
@@ -198,6 +209,14 @@
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
       "attributes" : { }
+    }, {
+      "id" : "f4116230-8642-4bb7-bbc8-db9c5c07b558",
+      "name" : "create-maintenance-message",
+      "description" : "${create-maintenance-message}",
+      "composite" : false,
+      "clientRole" : false,
+      "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
+      "attributes" : { }
     }, {
       "id" : "973f0999-cc70-4b28-9f43-979c470bea8e",
       "name" : "default-data-steward-roles",
@@ -249,6 +268,14 @@
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
       "attributes" : { }
+    }, {
+      "id" : "272a79a7-e282-4261-8f7d-5d5d1364243a",
+      "name" : "update-maintenance-message",
+      "description" : "${update-maintenance-message}",
+      "composite" : false,
+      "clientRole" : false,
+      "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
+      "attributes" : { }
     }, {
       "id" : "64c16bfb-2015-48ad-a23f-637ff24419cb",
       "name" : "default-query-handling",
@@ -268,6 +295,14 @@
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
       "attributes" : { }
+    }, {
+      "id" : "c047d521-cec3-4444-86c4-aef098489b7b",
+      "name" : "delete-maintenance-message",
+      "description" : "${delete-maintenance-message}",
+      "composite" : false,
+      "clientRole" : false,
+      "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
+      "attributes" : { }
     }, {
       "id" : "e14ab76b-1c24-484d-ae2d-478b8457edea",
       "name" : "list-licenses",
@@ -435,7 +470,7 @@
       "description" : "${default-developer-roles}",
       "composite" : true,
       "composites" : {
-        "realm" : [ "escalated-query-handling", "default-table-handling", "escalated-database-handling", "default-container-handling", "default-query-handling", "default-user-handling", "default-database-handling", "escalated-container-handling", "escalated-table-handling", "default-identifier-handling" ]
+        "realm" : [ "escalated-query-handling", "default-table-handling", "escalated-database-handling", "default-container-handling", "default-query-handling", "default-user-handling", "default-database-handling", "default-maintenance-handling", "escalated-container-handling", "escalated-table-handling", "default-identifier-handling" ]
       },
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
@@ -504,6 +539,14 @@
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
       "attributes" : { }
+    }, {
+      "id" : "d6e38368-b40f-423b-82e4-e8aa595237c9",
+      "name" : "find-maintenance-message",
+      "description" : "${find-maintenance-message}",
+      "composite" : false,
+      "clientRole" : false,
+      "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
+      "attributes" : { }
     }, {
       "id" : "fd1cc463-3e67-49d9-81b8-2cd90c1daa9c",
       "name" : "check-database-access",
@@ -539,6 +582,14 @@
       "clientRole" : false,
       "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
       "attributes" : { }
+    }, {
+      "id" : "09f7bdb0-296f-46c8-a3a3-8f9254fb17e4",
+      "name" : "list-maintenance-messages",
+      "description" : "${list-maintenance-messages}",
+      "composite" : false,
+      "clientRole" : false,
+      "containerId" : "82c39861-d877-4667-a0f3-4daa2ee230e0",
+      "attributes" : { }
     }, {
       "id" : "fe3bc45c-61c2-4ece-bcaf-d410dc7de501",
       "name" : "update-database-access",
@@ -909,7 +960,7 @@
   "otpPolicyLookAheadWindow" : 1,
   "otpPolicyPeriod" : 30,
   "otpPolicyCodeReusable" : false,
-  "otpSupportedApplications" : [ "totpAppFreeOTPName", "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName" ],
+  "otpSupportedApplications" : [ "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName", "totpAppFreeOTPName" ],
   "webAuthnPolicyRpEntityName" : "keycloak",
   "webAuthnPolicySignatureAlgorithms" : [ "ES256" ],
   "webAuthnPolicyRpId" : "",
@@ -1932,7 +1983,7 @@
       "subType" : "authenticated",
       "subComponents" : { },
       "config" : {
-        "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-address-mapper", "saml-user-attribute-mapper" ]
+        "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper" ]
       }
     }, {
       "id" : "3ab11d74-5e76-408a-b85a-26bf8950f979",
@@ -1941,7 +1992,7 @@
       "subType" : "anonymous",
       "subComponents" : { },
       "config" : {
-        "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper" ]
+        "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper" ]
       }
     } ],
     "org.keycloak.keys.KeyProvider" : [ {
@@ -1993,7 +2044,7 @@
   "internationalizationEnabled" : false,
   "supportedLocales" : [ ],
   "authenticationFlows" : [ {
-    "id" : "7e7d6810-5b6c-4ec6-865c-5f0b62ec56d7",
+    "id" : "a50c392e-a870-484c-b7c4-7c85e886ee46",
     "alias" : "Account verification options",
     "description" : "Method with which to verity the existing account",
     "providerId" : "basic-flow",
@@ -2015,7 +2066,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "6d972ab3-0618-4971-b44a-0fc0d11c7280",
+    "id" : "3f384243-bf93-4535-8811-b23526f12963",
     "alias" : "Authentication Options",
     "description" : "Authentication options.",
     "providerId" : "basic-flow",
@@ -2044,7 +2095,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "821a14e0-ef26-4b07-b716-fa34393eda56",
+    "id" : "478c7d4f-86f2-42eb-91db-4bc03e20e416",
     "alias" : "Browser - Conditional OTP",
     "description" : "Flow to determine if the OTP is required for the authentication",
     "providerId" : "basic-flow",
@@ -2066,7 +2117,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "e70eadbd-4c39-4cfd-86ac-e50acc753b1b",
+    "id" : "cca43646-a74e-4439-b029-3c0a11cd3693",
     "alias" : "Direct Grant - Conditional OTP",
     "description" : "Flow to determine if the OTP is required for the authentication",
     "providerId" : "basic-flow",
@@ -2088,7 +2139,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "4e35af97-acf4-4ca8-bc81-0477c1adfb6d",
+    "id" : "815189fe-ef5f-4865-be03-1e4b5dd9eafe",
     "alias" : "First broker login - Conditional OTP",
     "description" : "Flow to determine if the OTP is required for the authentication",
     "providerId" : "basic-flow",
@@ -2110,7 +2161,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "2e0bd063-274a-4aab-a5f0-038a0bca5b98",
+    "id" : "b72709d3-246d-4057-a82f-1a6e721bdd0c",
     "alias" : "Handle Existing Account",
     "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider",
     "providerId" : "basic-flow",
@@ -2132,7 +2183,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "6a20fab2-44bb-4451-b29a-6fb7e14a52ce",
+    "id" : "191c4793-7fdd-449a-8218-70d047d882b7",
     "alias" : "Reset - Conditional OTP",
     "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
     "providerId" : "basic-flow",
@@ -2154,7 +2205,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "159d7398-74a7-4f60-a3fd-eb2df46f5ce7",
+    "id" : "afd9a108-753a-4012-a65f-69d149d8313a",
     "alias" : "User creation or linking",
     "description" : "Flow for the existing/non-existing user alternatives",
     "providerId" : "basic-flow",
@@ -2177,7 +2228,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "85a66c55-4665-4ba0-bec9-7254eb8e5895",
+    "id" : "95c1347f-8248-4506-8ac7-77da64331658",
     "alias" : "Verify Existing Account by Re-authentication",
     "description" : "Reauthentication of existing account",
     "providerId" : "basic-flow",
@@ -2199,7 +2250,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "c002e6da-2397-4fae-8d48-1eec3719ca15",
+    "id" : "00aa962b-69e7-4032-99d6-33d2e15689bf",
     "alias" : "browser",
     "description" : "browser based authentication",
     "providerId" : "basic-flow",
@@ -2235,7 +2286,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "a03631cf-2fea-4a12-a35c-8137023503bd",
+    "id" : "b7efe33a-b00a-4830-b362-739101e1a437",
     "alias" : "clients",
     "description" : "Base authentication for clients",
     "providerId" : "client-flow",
@@ -2271,7 +2322,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "a89940e4-bf4d-4a04-8fdf-dcf775336b20",
+    "id" : "39a89fb2-74b0-44bb-877c-a2f75044f5e1",
     "alias" : "direct grant",
     "description" : "OpenID Connect Resource Owner Grant",
     "providerId" : "basic-flow",
@@ -2300,7 +2351,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "2dc2582b-be6f-4d9a-b545-b2c0e79a3581",
+    "id" : "c0b9af60-9bb2-42e2-815d-c03144b4773c",
     "alias" : "docker auth",
     "description" : "Used by Docker clients to authenticate against the IDP",
     "providerId" : "basic-flow",
@@ -2315,7 +2366,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "09e56692-226f-4384-85e0-e33463cdb226",
+    "id" : "50e548b3-60a1-4511-838a-894462d7437a",
     "alias" : "first broker login",
     "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
     "providerId" : "basic-flow",
@@ -2338,7 +2389,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "1439c900-92e0-4230-a1a7-ae82c3b8ddc9",
+    "id" : "260eb8b4-8889-41e4-b3d4-ce8adf15474c",
     "alias" : "forms",
     "description" : "Username, password, otp and other auth forms.",
     "providerId" : "basic-flow",
@@ -2360,7 +2411,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "4cc3bb1b-e85d-447e-b50e-1afbe107bafe",
+    "id" : "d7db5234-08e2-44ed-8708-5f4e906fe8a5",
     "alias" : "http challenge",
     "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes",
     "providerId" : "basic-flow",
@@ -2382,7 +2433,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "04c49d80-30e4-4a37-b1c7-4d18c1b6a7f1",
+    "id" : "73ada652-053b-42f3-be1f-af907cdd3151",
     "alias" : "registration",
     "description" : "registration flow",
     "providerId" : "basic-flow",
@@ -2398,7 +2449,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "85abb75a-0774-4b2d-8a71-2a92b0cfb639",
+    "id" : "ef0d04cc-a30d-4cdc-8e62-26075e256f41",
     "alias" : "registration form",
     "description" : "registration form",
     "providerId" : "form-flow",
@@ -2434,7 +2485,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "948f68c1-015b-4349-a56f-6ee177d558ce",
+    "id" : "c6db550e-4542-4474-8e3d-955fb50b1a05",
     "alias" : "reset credentials",
     "description" : "Reset credentials for a user if they forgot their password or something",
     "providerId" : "basic-flow",
@@ -2470,7 +2521,7 @@
       "userSetupAllowed" : false
     } ]
   }, {
-    "id" : "6046d416-4a88-4af6-b440-9fbc87fba478",
+    "id" : "9ba81baf-f271-44df-b712-1a58af78c27c",
     "alias" : "saml ecp",
     "description" : "SAML ECP Profile Authentication Flow",
     "providerId" : "basic-flow",
@@ -2486,13 +2537,13 @@
     } ]
   } ],
   "authenticatorConfig" : [ {
-    "id" : "3c91aefc-127f-4722-8375-72e8434d6266",
+    "id" : "d70d482f-3f61-4514-918f-9634c6dc7200",
     "alias" : "create unique user config",
     "config" : {
       "require.password.update.after.registration" : "false"
     }
   }, {
-    "id" : "1041c583-4682-44a8-b61b-9712bd4987c4",
+    "id" : "b5e8a2b3-a407-42f4-bc5d-c82c2c77c8d1",
     "alias" : "review profile config",
     "config" : {
       "update.profile.on.first.login" : "missing"
diff --git a/dbrepo-authentication-service/generate-keystore.sh b/dbrepo-authentication-service/generate-keystore.sh
new file mode 100644
index 0000000000..8b68c44a1f
--- /dev/null
+++ b/dbrepo-authentication-service/generate-keystore.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+keytool -genkey -alias server -keyalg RSA -keypass password -storepass password -keystore server.keystore
\ No newline at end of file
diff --git a/dbrepo-authentication-service/server.keystore b/dbrepo-authentication-service/server.keystore
new file mode 100644
index 0000000000000000000000000000000000000000..9dcd5051210b5bd2f945a8325610684c8e0029a8
GIT binary patch
literal 2776
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
XGs=$kMvoZ+C%<97mSy`DR73#)a~}c>

literal 0
HcmV?d00001

diff --git a/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageBriefDto.java b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageBriefDto.java
new file mode 100644
index 0000000000..a11c70f621
--- /dev/null
+++ b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageBriefDto.java
@@ -0,0 +1,33 @@
+package at.tuwien.api.maintenance;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.*;
+import lombok.extern.jackson.Jacksonized;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Jacksonized
+@ToString
+public class BannerMessageBriefDto {
+
+    @NotNull
+    private BannerMessageTypeDto type;
+
+    @NotBlank
+    @Schema(example = "Maintenance starts on 8am on Monday")
+    private String message;
+
+    @Schema(example = "https://example.com")
+    private String link;
+
+    @JsonProperty("link_text")
+    @Schema(example = "More")
+    private String linkText;
+
+}
diff --git a/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageCreateDto.java b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageCreateDto.java
new file mode 100644
index 0000000000..8bfe43b2b1
--- /dev/null
+++ b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageCreateDto.java
@@ -0,0 +1,44 @@
+package at.tuwien.api.maintenance;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.*;
+import lombok.extern.jackson.Jacksonized;
+
+import java.time.Instant;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Jacksonized
+@ToString
+public class BannerMessageCreateDto {
+
+    @NotNull
+    private BannerMessageTypeDto type;
+
+    @NotBlank
+    @Schema(example = "Maintenance starts on 8am on Monday")
+    private String message;
+
+    @Schema(example = "https://example.com")
+    private String link;
+
+    @JsonProperty("link_text")
+    @Schema(example = "More")
+    private String linkText;
+
+    @JsonProperty("display_start")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+    private Instant displayStart;
+
+    @JsonProperty("display_end")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+    private Instant displayEnd;
+
+}
diff --git a/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageDto.java b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageDto.java
new file mode 100644
index 0000000000..1b822b9ed4
--- /dev/null
+++ b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageDto.java
@@ -0,0 +1,47 @@
+package at.tuwien.api.maintenance;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.*;
+import lombok.extern.jackson.Jacksonized;
+
+import java.time.Instant;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Jacksonized
+@ToString
+public class BannerMessageDto {
+
+    @NotNull
+    private Long id;
+
+    @NotNull
+    private BannerMessageTypeDto type;
+
+    @NotBlank
+    @Schema(example = "Maintenance starts on 8am on Monday")
+    private String message;
+
+    @Schema(example = "https://example.com")
+    private String link;
+
+    @JsonProperty("link_text")
+    @Schema(example = "More")
+    private String linkText;
+
+    @JsonProperty("display_start")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC")
+    private Instant displayStart;
+
+    @JsonProperty("display_end")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC")
+    private Instant displayEnd;
+
+}
diff --git a/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageTypeDto.java b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageTypeDto.java
new file mode 100644
index 0000000000..8a867f5ea4
--- /dev/null
+++ b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageTypeDto.java
@@ -0,0 +1,28 @@
+package at.tuwien.api.maintenance;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Getter;
+
+@Getter
+public enum BannerMessageTypeDto {
+
+    @JsonProperty("error")
+    ERROR("error"),
+
+    @JsonProperty("warning")
+    WARNING("warning"),
+
+    @JsonProperty("info")
+    INFO("info");
+
+    private String name;
+
+    BannerMessageTypeDto(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public String toString() {
+        return this.name;
+    }
+}
diff --git a/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageUpdateDto.java b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageUpdateDto.java
new file mode 100644
index 0000000000..26a2ac68ad
--- /dev/null
+++ b/dbrepo-metadata-db/api/src/main/java/at/tuwien/api/maintenance/BannerMessageUpdateDto.java
@@ -0,0 +1,44 @@
+package at.tuwien.api.maintenance;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.*;
+import lombok.extern.jackson.Jacksonized;
+
+import java.time.Instant;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Jacksonized
+@ToString
+public class BannerMessageUpdateDto {
+
+    @NotNull
+    private BannerMessageTypeDto type;
+
+    @NotBlank
+    @Schema(example = "Maintenance starts on 8am on Monday")
+    private String message;
+
+    @Schema(example = "https://example.com")
+    private String link;
+
+    @JsonProperty("link_text")
+    @Schema(example = "More")
+    private String linkText;
+
+    @JsonProperty("display_start")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+    private Instant displayStart;
+
+    @JsonProperty("display_end")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+    private Instant displayEnd;
+
+}
diff --git a/dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessage.java b/dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessage.java
new file mode 100644
index 0000000000..f3cf82d499
--- /dev/null
+++ b/dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessage.java
@@ -0,0 +1,43 @@
+package at.tuwien.entities.maintenance;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.GenericGenerator;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.Instant;
+
+@Data
+@Entity
+@Builder
+@ToString
+@AllArgsConstructor
+@NoArgsConstructor
+@EntityListeners(AuditingEntityListener.class)
+@Table(name = "mdb_banner_messages")
+@NamedQueries({
+        @NamedQuery(name = "BannerMessage.findByActive", query = "select m from BannerMessage m where (m.displayStart = null and m.displayEnd = null) or (m.displayStart = null and m.displayEnd >= NOW()) or (m.displayStart <= NOW() and m.displayEnd >= NOW()) or (m.displayStart <= NOW() and m.displayEnd = null)")
+})
+public class BannerMessage {
+
+    @Id
+    @EqualsAndHashCode.Include
+    @GeneratedValue(generator = "messages-sequence")
+    @GenericGenerator(name = "messages-sequence", strategy = "increment")
+    @Column(updatable = false, nullable = false)
+    private Long id;
+
+    @Enumerated(EnumType.STRING)
+    @Column(nullable = false, columnDefinition = "enum('ERROR','WARNING','INFO')")
+    private BannerMessageType type;
+
+    @Column(nullable = false)
+    private String message;
+
+    @Column(columnDefinition = "TIMESTAMP")
+    private Instant displayStart;
+
+    @Column(columnDefinition = "TIMESTAMP")
+    private Instant displayEnd;
+
+}
diff --git a/dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessageType.java b/dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessageType.java
new file mode 100644
index 0000000000..8d17965f48
--- /dev/null
+++ b/dbrepo-metadata-db/entities/src/main/java/at/tuwien/entities/maintenance/BannerMessageType.java
@@ -0,0 +1,13 @@
+
+package at.tuwien.entities.maintenance;
+
+import lombok.Getter;
+import lombok.ToString;
+
+@Getter
+@ToString
+public enum BannerMessageType {
+    WARNING,
+    ERROR,
+    INFO;
+}
\ No newline at end of file
diff --git a/dbrepo-metadata-db/setup-schema.sql b/dbrepo-metadata-db/setup-schema.sql
index eb7809d140..24798819e9 100644
--- a/dbrepo-metadata-db/setup-schema.sql
+++ b/dbrepo-metadata-db/setup-schema.sql
@@ -332,6 +332,18 @@ CREATE TABLE IF NOT EXISTS `fda`.`mdb_view`
     FOREIGN KEY (vdbid) REFERENCES mdb_databases (id)
 ) WITH SYSTEM VERSIONING;
 
+CREATE TABLE IF NOT EXISTS `fda`.`mdb_banner_messages`
+(
+    id            bigint                            NOT NULL AUTO_INCREMENT,
+    type          ENUM ('ERROR', 'WARNING', 'INFO') NOT NULL default 'INFO',
+    message       TEXT                              NOT NULL,
+    link          TEXT                              NULL,
+    link_text     VARCHAR(255)                      NULL,
+    display_start timestamp                         NULL,
+    display_end   timestamp                         NULL,
+    PRIMARY KEY (id)
+) WITH SYSTEM VERSIONING;
+
 CREATE TABLE IF NOT EXISTS `fda`.`mdb_view_columns`
 (
     id       BIGINT  NOT NULL AUTO_INCREMENT,
diff --git a/dbrepo-metadata-service/pom.xml b/dbrepo-metadata-service/pom.xml
index 6c3514de1e..8dfab218ee 100644
--- a/dbrepo-metadata-service/pom.xml
+++ b/dbrepo-metadata-service/pom.xml
@@ -114,6 +114,12 @@
             <version>${project.version}</version>
             <scope>compile</scope>
         </dependency>
+        <!-- Authentication -->
+        <dependency>
+            <groupId>com.auth0</groupId>
+            <artifactId>java-jwt</artifactId>
+            <version>${jwt.version}</version>
+        </dependency>
         <!-- IDE -->
         <dependency>
             <groupId>org.projectlombok</groupId>
diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
index 13d92092fc..7bec3fbf94 100644
--- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
+++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
@@ -2,7 +2,6 @@ package at.tuwien.config;
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.http.HttpMethod;
 import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
diff --git a/dbrepo-query-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java b/dbrepo-query-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
index 582fe83630..1c36be92a4 100644
--- a/dbrepo-query-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
+++ b/dbrepo-query-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
@@ -5,7 +5,6 @@ import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
 import io.swagger.v3.oas.annotations.security.SecurityScheme;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.http.HttpMethod;
 import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
diff --git a/dbrepo-ui/api/metadata.service.js b/dbrepo-ui/api/metadata.service.js
new file mode 100644
index 0000000000..9c02ea9247
--- /dev/null
+++ b/dbrepo-ui/api/metadata.service.js
@@ -0,0 +1,104 @@
+import Vue from 'vue'
+import api from '@/api'
+
+class MetadataService {
+  findAllMessages () {
+    return new Promise((resolve, reject) => {
+      api.get('/api/maintenance/message', { headers: { Accept: 'application/json' } })
+        .then((response) => {
+          const messages = response.data
+          console.debug('response messages', messages)
+          resolve(messages)
+        })
+        .catch((error) => {
+          const { code, message } = error
+          console.error('Failed to load messages', error)
+          Vue.$toast.error(`[${code}] Failed to load messages: ${message}`)
+          reject(error)
+        })
+    })
+  }
+
+  createMessage (data) {
+    return new Promise((resolve, reject) => {
+      api.post('/api/maintenance/message', data, { headers: { Accept: 'application/json' } })
+        .then((response) => {
+          const messages = response.data
+          console.debug('response message', messages)
+          resolve(messages)
+        })
+        .catch((error) => {
+          const { code, message } = error
+          console.error('Failed to create message', error)
+          Vue.$toast.error(`[${code}] Failed to create message: ${message}`)
+          reject(error)
+        })
+    })
+  }
+
+  findMessage (id) {
+    return new Promise((resolve, reject) => {
+      api.get(`/api/maintenance/message/${id}`, { headers: { Accept: 'application/json' } })
+        .then((response) => {
+          const messages = response.data
+          console.debug('response message', messages)
+          resolve(messages)
+        })
+        .catch((error) => {
+          const { code, message } = error
+          console.error('Failed to find message', error)
+          Vue.$toast.error(`[${code}] Failed to find message: ${message}`)
+          reject(error)
+        })
+    })
+  }
+
+  updateMessage (id, data) {
+    return new Promise((resolve, reject) => {
+      api.put(`/api/maintenance/message/${id}`, data, { headers: { Accept: 'application/json' } })
+        .then((response) => {
+          const messages = response.data
+          console.debug('response message', messages)
+          resolve(messages)
+        })
+        .catch((error) => {
+          const { code, message } = error
+          console.error('Failed to update message', error)
+          Vue.$toast.error(`[${code}] Failed to update message: ${message}`)
+          reject(error)
+        })
+    })
+  }
+
+  deleteMessage (id) {
+    return new Promise((resolve, reject) => {
+      api.delete(`/api/maintenance/message/${id}`, { headers: { Accept: 'application/json' } })
+        .then(() => resolve())
+        .catch((error) => {
+          const { code, message } = error
+          console.error('Failed to delete message', error)
+          Vue.$toast.error(`[${code}] Failed to delete message: ${message}`)
+          reject(error)
+        })
+    })
+  }
+
+  findActiveMessages () {
+    return new Promise((resolve, reject) => {
+      api.get('/api/maintenance/message/active', { headers: { Accept: 'application/json' } })
+        .then((response) => {
+          const messages = response.data
+          console.debug('response messages', messages)
+          resolve(messages)
+        })
+        .catch((error) => {
+          const { code, message } = error
+          console.error('Failed to load active messages', error)
+          Vue.$toast.error(`[${code}] Failed to load active messages: ${message}`)
+          reject(error)
+        })
+    })
+  }
+}
+
+export default new MetadataService()
diff --git a/dbrepo-ui/components/DBToolbar.vue b/dbrepo-ui/components/DBToolbar.vue
index b8590bc855..c899ab03eb 100644
--- a/dbrepo-ui/components/DBToolbar.vue
+++ b/dbrepo-ui/components/DBToolbar.vue
@@ -55,7 +55,7 @@
           <v-tab :to="`/container/${$route.params.container_id}/database/${$route.params.database_id}/table`">
             {{ $t('databases.toolbar.tables', { name: 'vue-i18n' }) }}
           </v-tab>
-          <v-tab v-if="hasReadAccess" :to="`/container/${$route.params.container_id}/database/${$route.params.database_id}/query`">
+          <v-tab v-if="canViewQueries" :to="`/container/${$route.params.container_id}/database/${$route.params.database_id}/query`">
             {{ $t('databases.toolbar.subsets', { name: 'vue-i18n' }) }}
           </v-tab>
           <v-tab :to="`/container/${$route.params.container_id}/database/${$route.params.database_id}/view`">
@@ -121,6 +121,9 @@ export default {
       }
       return this.roles.includes('execute-query')
     },
+    canViewQueries () {
+      return this.database.is_public || this.hasReadAccess
+    },
     canCreateView () {
       if (!this.user || !this.isOwner) {
         return false
diff --git a/dbrepo-ui/components/DatabaseList.vue b/dbrepo-ui/components/DatabaseList.vue
index fc044ee2ff..bd1c650460 100644
--- a/dbrepo-ui/components/DatabaseList.vue
+++ b/dbrepo-ui/components/DatabaseList.vue
@@ -1,13 +1,16 @@
 <template>
   <div>
     <v-progress-linear v-if="loadingContainers || loadingDatabases" :indeterminate="!error" />
+    <v-card v-if="!$vuetify.theme.dark && containers.list > 0" flat tile>
+      <v-divider class="mx-4" />
+    </v-card>
     <v-card
       v-for="(container, idx) in containers"
       :key="idx"
       :to="link(container)"
       flat
       tile>
-      <v-divider v-if="!$vuetify.theme.dark" class="mx-4" />
+      <v-divider v-if="idx !== 0" class="mx-4" />
       <v-card-title v-if="!hasDatabase(container)" v-text="container.name" />
       <v-card-title v-if="hasDatabase(container)">
         <a :href="`/container/${container.id}/database/${container.database.id}`">{{ container.name }}</a>
diff --git a/dbrepo-ui/components/QueryList.vue b/dbrepo-ui/components/QueryList.vue
index 1bead29f29..4485e34945 100644
--- a/dbrepo-ui/components/QueryList.vue
+++ b/dbrepo-ui/components/QueryList.vue
@@ -1,11 +1,16 @@
 <template>
   <div>
-    <v-progress-linear v-if="loadingIdentifiers || loadingQueries || error" :color="loadingColor" :value="loadProgress" />
-    <v-card v-if="!(loadingIdentifiers || loadingQueries) && queries && queries.length === 0" flat>
+    <v-progress-linear v-if="loadingIdentifiers || loadingQueries || error" :color="loadingColor" :indeterminate="!error" />
+    <v-card v-if="!error && !(loadingIdentifiers || loadingQueries) && queries && queries.length === 0" flat tile>
       <v-card-text>
         (no subsets)
       </v-card-text>
     </v-card>
+    <v-card v-if="error" flat tile>
+      <v-card-text>
+        Failed to load queries: database is not reachable
+      </v-card-text>
+    </v-card>
     <v-tabs-items>
       <div v-if="!loadingQueries && !error">
         <div v-for="(item,i) in queries" :key="i">
@@ -77,7 +82,6 @@ export default {
     return {
       loadingQueries: false,
       loadingIdentifiers: false,
-      loadProgress: 0,
       error: false,
       queries: [],
       identifiers: []
@@ -129,7 +133,6 @@ export default {
   mounted () {
     this.loadQueries()
     this.loadIdentifiers()
-    this.simulateProgress()
   },
   methods: {
     loadIdentifiers () {
@@ -148,6 +151,9 @@ export default {
         .then((queries) => {
           this.queries = queries
         })
+        .catch(() => {
+          this.error = true
+        })
         .finally(() => {
           this.loadingQueries = false
         })
@@ -181,20 +187,6 @@ export default {
         return 'primary--text'
       }
       return null
-    },
-    simulateProgress () {
-      if (this.loadProgress !== 0) {
-        return
-      }
-      const timeout = 30 * 1000 /* ms */
-      const ticks = 100 /* ms */
-      let i = 0
-      setInterval(() => {
-        if (i++ >= timeout && !this.error) {
-          return
-        }
-        this.loadProgress = ((i * 100) / timeout) * 100
-      }, ticks)
     }
   }
 }
diff --git a/dbrepo-ui/components/TableToolbar.vue b/dbrepo-ui/components/TableToolbar.vue
index b3f22595c9..69351b3121 100644
--- a/dbrepo-ui/components/TableToolbar.vue
+++ b/dbrepo-ui/components/TableToolbar.vue
@@ -108,12 +108,18 @@ export default {
       return UserUtils.hasWriteAccess(this.access) && this.roles.includes('insert-table-data')
     },
     canEditTuple () {
+      if (this.selection === null || this.selection.length !== 1) {
+        return false
+      }
       if (!this.roles || !this.isDataTab) {
         return false
       }
       return UserUtils.hasWriteAccess(this.access) && this.roles.includes('insert-table-data')
     },
     canDeleteTuple () {
+      if (this.selection === null || this.selection.length < 1) {
+        return false
+      }
       if (!this.roles || !this.isDataTab) {
         return false
       }
@@ -199,7 +205,7 @@ export default {
           })
         TableService.deleteTuple(this.$route.params.container_id, this.$route.params.database_id, this.$route.params.table_id, { keys: constraints })
           .then(() => {
-            this.$toast.success(`Deleted ${this.selection.length} rows(s)`)
+            this.$toast.success(`Deleted ${this.selection.length} row${this.selection.length !== 1 ? 's' : ''}`)
             this.$emit('modified', { success: true, action: 'delete' })
           })
       }
diff --git a/dbrepo-ui/components/UserToolbar.vue b/dbrepo-ui/components/UserToolbar.vue
index 462b438496..878a480859 100644
--- a/dbrepo-ui/components/UserToolbar.vue
+++ b/dbrepo-ui/components/UserToolbar.vue
@@ -12,6 +12,9 @@
       <v-tab to="/user/authentication">
         Authentication
       </v-tab>
+      <v-tab v-if="canHandleMessages" to="/user/developer">
+        Developer
+      </v-tab>
     </v-tabs>
   </div>
 </template>
@@ -23,6 +26,29 @@ export default {
     return {
       tab: null
     }
+  },
+  computed: {
+    user () {
+      return this.$store.state.user
+    },
+    roles () {
+      return this.$store.state.roles
+    },
+    canCreateMessage () {
+      if (!this.roles) {
+        return false
+      }
+      return this.roles.includes('create-maintenance-message')
+    },
+    canModifyMessage () {
+      if (!this.roles) {
+        return false
+      }
+      return this.roles.includes('modify-maintenance-message')
+    },
+    canHandleMessages () {
+      return this.canCreateMessage || this.canModifyMessage
+    }
   }
 }
 </script>
diff --git a/dbrepo-ui/components/dialogs/EditMaintenanceMessage.vue b/dbrepo-ui/components/dialogs/EditMaintenanceMessage.vue
new file mode 100644
index 0000000000..4215123895
--- /dev/null
+++ b/dbrepo-ui/components/dialogs/EditMaintenanceMessage.vue
@@ -0,0 +1,228 @@
+<template>
+  <div>
+    <v-form ref="form" v-model="valid" autocomplete="off" @submit.prevent="submit">
+      <v-card>
+        <v-progress-linear v-if="loading" :color="loadingColor" :indeterminate="!error" />
+        <v-card-title v-text="title" />
+        <v-card-text>
+          <v-row dense>
+            <v-col>
+              <v-select
+                v-model="localMessage.type"
+                :items="types"
+                item-text="name"
+                item-value="value"
+                :rules="[v => !!v || $t('Required')]"
+                required
+                label="Type *" />
+            </v-col>
+          </v-row>
+          <v-row dense>
+            <v-col>
+              <v-text-field
+                v-model="localMessage.message"
+                :rules="[v => !!v || $t('Required')]"
+                required
+                label="Message *" />
+            </v-col>
+          </v-row>
+          <v-row dense>
+            <v-col cols="6">
+              <v-text-field
+                v-model="localMessage.display_start"
+                clearable
+                label="Start timestamp" />
+            </v-col>
+            <v-col cols="6">
+              <v-text-field
+                v-model="localMessage.display_end"
+                clearable
+                label="End timestamp" />
+            </v-col>
+          </v-row>
+          <v-row dense>
+            <v-col>
+              <v-text-field
+                v-model="localMessage.link"
+                clearable
+                label="Link" />
+            </v-col>
+          </v-row>
+          <v-row dense>
+            <v-col>
+              <v-text-field
+                v-model="localMessage.link_text"
+                clearable
+                label="Link Text" />
+            </v-col>
+          </v-row>
+        </v-card-text>
+        <v-card-actions>
+          <v-btn
+            v-if="isModification"
+            class="ml-2"
+            color="error"
+            @click="deleteMessage">
+            Delete
+          </v-btn>
+          <v-spacer />
+          <v-btn
+            class="mb-2"
+            @click="cancel">
+            Cancel
+          </v-btn>
+          <v-btn
+            id="database"
+            class="mb-2 ml-3 mr-2"
+            :disabled="!valid || loading"
+            :color="buttonColor"
+            type="submit"
+            :loading="loading"
+            @click="submitButton">
+            {{ buttonText }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-form>
+  </div>
+</template>
+
+<script>
+import MetadataService from '@/api/metadata.service'
+
+export default {
+  props: {
+    id: {
+      type: Number,
+      default () {
+        return null
+      }
+    }
+  },
+  data () {
+    return {
+      valid: false,
+      loading: false,
+      error: false,
+      types: [
+        { name: 'Error', value: 'error' },
+        { name: 'Warning', value: 'warning' },
+        { name: 'Info', value: 'info' }
+      ],
+      localMessage: {
+        type: null,
+        message: null,
+        display_start: null,
+        display_end: null,
+        link: null,
+        link_text: null
+      },
+      modify: {
+        username: null,
+        type: null
+      }
+    }
+  },
+  computed: {
+    loadingColor () {
+      return this.error ? 'red lighten-2' : 'primary'
+    },
+    database () {
+      return this.$store.state.database
+    },
+    title () {
+      return (!this.isModification ? 'Create' : 'Modify') + ' maintenance message'
+    },
+    buttonColor () {
+      if (this.modify.type && this.modify.type === 'revoke') {
+        return 'error'
+      }
+      return 'secondary'
+    },
+    isModification () {
+      return this.id !== null
+    },
+    buttonText () {
+      return (this.isModification ? 'Modify' : 'Create') + ' message'
+    }
+  },
+  watch: {
+    id () {
+      this.init()
+    }
+  },
+  mounted () {
+    this.init()
+  },
+  methods: {
+    submit () {
+      this.$refs.form.validate()
+    },
+    cancel () {
+      this.$emit('close-dialog', { success: false })
+    },
+    init () {
+      if (!this.id) {
+        this.localMessage = {
+          type: null,
+          message: null,
+          display_start: null,
+          display_end: null,
+          link: null,
+          link_text: null
+        }
+      } else {
+        this.loadMessage(this.id)
+      }
+    },
+    loadMessage (id) {
+      MetadataService.findMessage(id)
+        .then((message) => {
+          this.localMessage = message
+        })
+    },
+    submitButton () {
+      if (this.isModification) {
+        this.updateMessage()
+      } else {
+        this.createMessage()
+      }
+    },
+    createMessage () {
+      this.loading = true
+      MetadataService.createMessage(this.localMessage)
+        .then(() => {
+          this.$emit('close-dialog', { success: true })
+          this.$emit('reload-messages', { success: true })
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+    updateMessage () {
+      this.loading = true
+      const payload = Object.assign({}, this.localMessage)
+      delete payload.id
+      MetadataService.updateMessage(this.localMessage.id, payload)
+        .then(() => {
+          this.$emit('close-dialog', { success: true })
+          this.$emit('reload-messages', { success: true })
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+    deleteMessage () {
+      this.loading = true
+      MetadataService.deleteMessage(this.localMessage.id)
+        .then(() => {
+          this.$emit('close-dialog', { success: true })
+          this.$emit('reload-messages', { success: true })
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    }
+  }
+}
+</script>
diff --git a/dbrepo-ui/layouts/default.vue b/dbrepo-ui/layouts/default.vue
index 8f9358cb23..061bfc620d 100644
--- a/dbrepo-ui/layouts/default.vue
+++ b/dbrepo-ui/layouts/default.vue
@@ -39,6 +39,19 @@
           </v-list-item-content>
         </v-list-item>
       </v-list>
+      <div id="messages">
+        <v-alert
+          v-for="(message, idx) in messages"
+          :key="idx"
+          class="banner"
+          border="left"
+          tile
+          :type="message.type">
+          {{ message.message }}
+          <span v-if="message.link">&dash;</span>
+          <a v-if="message.link" :href="message.link">{{ message.link_text ? message.link_text : message.link }}</a>
+        </v-alert>
+      </div>
     </v-navigation-drawer>
     <v-form ref="form" @submit.prevent="submit">
       <v-app-bar fixed app>
@@ -70,8 +83,8 @@
             {{ $t('layout.signup', { name: 'vue-i18n' }) }}
           </v-btn>
         </div>
-        <div>
-          <v-btn v-if="user" to="/user" plain>
+        <div v-if="user">
+          <v-btn to="/user" plain>
             {{ user.username }}
           </v-btn>
           <v-menu bottom offset-y left>
@@ -105,19 +118,6 @@
         <nuxt />
       </v-container>
     </v-main>
-    <v-footer
-      v-if="sandbox"
-      padless>
-      <v-card
-        flat
-        tile
-        width="100%"
-        class="banner text-center">
-        <v-card-text class="black--text">
-          This is a <strong>TEST</strong> environment, do not use production/confidential data! — <a href="//github.com/fair-data-austria/dbrepo/issues/new" class="black--text">Report a bug</a>
-        </v-card-text>
-      </v-card>
-    </v-footer>
   </v-app>
 </template>
 
@@ -160,6 +160,9 @@ export default {
     locale () {
       return this.$store.state.locale
     },
+    messages () {
+      return this.$store.state.messages
+    },
     table () {
       return this.$store.state.table
     },
@@ -215,9 +218,7 @@ export default {
     }
   },
   mounted () {
-    if (this.refreshToken) {
-      AuthenticationService.authenticateToken(this.refreshToken)
-    }
+    this.$store.dispatch('reloadMessages')
     if (this.locale) {
       this.$i18n.locale = this.locale
     }
@@ -302,6 +303,16 @@ export default {
 }
 </script>
 <style>
+#messages {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+.banner {
+  width: 100%;
+  margin: 8px 0 0 0;
+}
 .search-result-title,
 .search-result-subtitle {
   overflow: hidden;
diff --git a/dbrepo-ui/nuxt.config.js b/dbrepo-ui/nuxt.config.js
index 9c06aff0c1..9db2e6dc80 100644
--- a/dbrepo-ui/nuxt.config.js
+++ b/dbrepo-ui/nuxt.config.js
@@ -110,11 +110,10 @@ export default {
           primary: colors.blue.darken2,
           accent: colors.amber.darken3,
           secondary: colors.blueGrey.base,
-          info: colors.amber.lighten1,
+          info: colors.blue.lighten2,
           code: colors.grey.lighten4,
           warning: colors.orange.lighten2,
           error: colors.red.base /* is used by forms */,
-          banner: colors.red.lighten2,
           success: colors.teal.base
         },
         dark: {
diff --git a/dbrepo-ui/pages/container/_container_id/database/_database_id/query/_query_id/index.vue b/dbrepo-ui/pages/container/_container_id/database/_database_id/query/_query_id/index.vue
index dcbe339660..eddbcca9f4 100644
--- a/dbrepo-ui/pages/container/_container_id/database/_database_id/query/_query_id/index.vue
+++ b/dbrepo-ui/pages/container/_container_id/database/_database_id/query/_query_id/index.vue
@@ -25,7 +25,7 @@
         </DownloadButton>
       </v-toolbar-title>
     </v-toolbar>
-    <v-card v-if="query && query.identifier" flat tile>
+    <v-card v-if="query.identifier" flat tile>
       <v-card-title>Identifier</v-card-title>
       <v-card-text>
         <v-list dense>
@@ -38,7 +38,7 @@
                 Persistent Identifier
               </v-list-item-title>
               <v-list-item-content>
-                <Banner :identifier="query.identifier" />
+                <Banner v-if="canPersistQuery" :identifier="query.identifier" />
               </v-list-item-content>
               <v-list-item-title class="mt-2">
                 Title
@@ -306,7 +306,7 @@ export default {
       return this.query.result_hash
     },
     canPersistQuery () {
-      if (!this.query || this.query.is_persisted) {
+      if (this.loadingQuery || !this.query || this.query.is_persisted) {
         return false
       }
       return UserUtils.hasReadAccess(this.access)
diff --git a/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/data.vue b/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/data.vue
index dfd27fc763..925403b70d 100644
--- a/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/data.vue
+++ b/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/data.vue
@@ -23,8 +23,14 @@
       </v-toolbar-title>
     </v-toolbar>
     <v-card tile>
-      <v-progress-linear v-if="loadingData > 0 || error" :value="loadProgress" :color="error ? 'error' : 'primary'" />
+      <v-progress-linear v-if="loadingData > 0 || error" :indeterminate="!error" :color="loadingColor" />
+      <v-card v-if="error" flat tile>
+        <v-card-text>
+          Failed to load table data: database is not reachable
+        </v-card-text>
+      </v-card>
       <v-data-table
+        v-if="!error"
         :headers="headers"
         :items="rows"
         :options.sync="options"
@@ -53,7 +59,6 @@ export default {
     return {
       loading: true,
       loadingData: 0,
-      loadProgress: 0,
       editTupleDialog: false,
       total: -1,
       footerProps: {
@@ -67,7 +72,8 @@ export default {
       version: null,
       lastReload: new Date(),
       tab: null,
-      error: false, // XXX: `error` is never changed
+      edit: false,
+      error: false,
       options: {
         page: 1,
         itemsPerPage: 10
@@ -85,7 +91,7 @@ export default {
   },
   computed: {
     loadingColor () {
-      return this.error ? 'red lighten-2' : 'primary'
+      return this.error ? 'error' : 'primary'
     },
     token () {
       return this.$store.state.token
@@ -168,7 +174,6 @@ export default {
   },
   mounted () {
     this.reload()
-    this.simulateProgress()
     this.loadProperties()
   },
   methods: {
@@ -277,6 +282,9 @@ export default {
             return row
           })
         })
+        .catch(() => {
+          this.error = true
+        })
         .finally(() => {
           this.loadingData--
         })
@@ -290,20 +298,6 @@ export default {
         .finally(() => {
           this.loadingData--
         })
-    },
-    simulateProgress () {
-      if (this.loadProgress !== 0) {
-        return
-      }
-      const timeout = 30 * 1000 /* ms */
-      const ticks = 100 /* ms */
-      let i = 0
-      setInterval(() => {
-        if (i++ >= timeout && !this.error) {
-          return
-        }
-        this.loadProgress = ((i * 100) / timeout) * 100
-      }, ticks)
     }
   }
 }
diff --git a/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/info.vue b/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/info.vue
index 171c0e219e..13fd456916 100644
--- a/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/info.vue
+++ b/dbrepo-ui/pages/container/_container_id/database/_database_id/table/_table_id/info.vue
@@ -83,10 +83,6 @@
               </v-list-item-title>
               <v-list-item-content v-if="canWriteQueues" class="amqp-consumer">
                 <span v-text="`${consumersUp}/${consumersTotal}`" />
-                <v-badge
-                  class="ml-1"
-                  :color="consumersState.color"
-                  :content="consumersState.text" />
               </v-list-item-content>
             </v-list-item-content>
           </v-list-item>
@@ -154,15 +150,6 @@ export default {
     access () {
       return this.$store.state.access
     },
-    consumersState () {
-      if (this.consumersTotal === 0 || this.consumersTotal - this.consumersUp > 0 || this.loadingConsumers) {
-        return { color: 'warning', text: 'pending' }
-      }
-      if (this.consumersTotal === 0) {
-        return { color: 'error', text: 'down' }
-      }
-      return { color: 'success', text: 'up' }
-    },
     consumersTotal () {
       return this.consumers.length
     },
diff --git a/dbrepo-ui/pages/user/authentication.vue b/dbrepo-ui/pages/user/authentication.vue
index 92af241111..877ae8ce16 100644
--- a/dbrepo-ui/pages/user/authentication.vue
+++ b/dbrepo-ui/pages/user/authentication.vue
@@ -40,7 +40,6 @@
                   </v-btn>
                 </v-col>
               </v-row>
-              <pre>{{ $refs.form3 }}</pre>
             </v-form>
           </v-card-text>
         </v-card>
diff --git a/dbrepo-ui/pages/user/developer.vue b/dbrepo-ui/pages/user/developer.vue
new file mode 100644
index 0000000000..7825e56243
--- /dev/null
+++ b/dbrepo-ui/pages/user/developer.vue
@@ -0,0 +1,133 @@
+<template>
+  <div v-if="canHandleMessages">
+    <UserToolbar />
+    <v-tabs-items v-model="tab">
+      <v-tab-item>
+        <v-card flat tile>
+          <v-card-title>Maintenance Messages</v-card-title>
+          <v-data-table
+            :headers="headers"
+            :items="messages"
+            :loading="loadingMessages"
+            :items-per-page="10">
+            <template v-slot:item.action="{ item }">
+              <v-btn
+                x-small
+                @click="modifyMessage(item)">
+                Modify
+              </v-btn>
+            </template>
+          </v-data-table>
+          <v-card-text>
+            <v-btn
+              small
+              color="secondary"
+              :disabled="!canCreateMessage"
+              @click="createMessage">
+              Create Message
+            </v-btn>
+          </v-card-text>
+        </v-card>
+      </v-tab-item>
+    </v-tabs-items>
+    <v-dialog
+      v-model="dialog"
+      persistent
+      max-width="640">
+      <EditMaintenanceMessage :id="messageId" @close-dialog="closeDialog" />
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import UserToolbar from '@/components/UserToolbar'
+import MetadataService from '@/api/metadata.service'
+import EditMaintenanceMessage from '@/components/dialogs/EditMaintenanceMessage'
+import { isActiveMessage } from '@/utils'
+
+export default {
+  components: {
+    UserToolbar,
+    EditMaintenanceMessage
+  },
+  data () {
+    return {
+      tab: 0,
+      headers: [
+        { text: 'Active', value: 'active' },
+        { text: 'Type', value: 'type' },
+        { text: 'Message', value: 'message' },
+        { text: 'Action', value: 'action' }
+      ],
+      messages: [],
+      loadingMessages: false,
+      dialog: false,
+      messageId: null
+    }
+  },
+  computed: {
+    token () {
+      return this.$store.state.token
+    },
+    user () {
+      return this.$store.state.user
+    },
+    roles () {
+      return this.$store.state.roles
+    },
+    canCreateMessage () {
+      if (!this.roles) {
+        return false
+      }
+      return this.roles.includes('create-maintenance-message')
+    },
+    canModifyMessage () {
+      if (!this.roles) {
+        return false
+      }
+      return this.roles.includes('modify-maintenance-message')
+    },
+    canHandleMessages () {
+      return this.canCreateMessage || this.canModifyMessage
+    }
+  },
+  mounted () {
+    this.loadMessages()
+  },
+  methods: {
+    submit () {
+    },
+    modifyMessage (message) {
+      this.messageId = message.id
+      this.dialog = true
+    },
+    createMessage () {
+      this.messageId = null
+      this.dialog = true
+    },
+    loadMessages () {
+      MetadataService.findAllMessages()
+        .then((messages) => {
+          this.messages = messages.map((message) => {
+            message.active = isActiveMessage(message) ? '● true' : 'false'
+            message.action = 'hello'
+            return message
+          })
+        })
+        .catch(() => {
+          this.loadingMessages = false
+        })
+        .finally(() => {
+          this.loadingMessages = false
+        })
+    },
+    closeDialog (event) {
+      this.dialog = false
+      if (event.success) {
+        this.loadMessages()
+        this.$store.dispatch('reloadMessages')
+      }
+    }
+  }
+}
+</script>
diff --git a/dbrepo-ui/store/index.js b/dbrepo-ui/store/index.js
index e42e49e0d0..690814ed85 100644
--- a/dbrepo-ui/store/index.js
+++ b/dbrepo-ui/store/index.js
@@ -3,6 +3,7 @@ import Vuex, { Store } from 'vuex'
 import UserService from '@/api/user.service'
 import DatabaseService from '@/api/database.service'
 import TableService from '@/api/table.service'
+import MetadataService from '@/api/metadata.service'
 
 Vue.use(Vuex)
 
@@ -16,7 +17,8 @@ const store = new Store({
     database: null,
     table: null,
     access: null,
-    locale: null
+    locale: null,
+    messages: []
   },
   getters: {
     getToken: state => state.token,
@@ -26,7 +28,8 @@ const store = new Store({
     getDatabase: state => state.database,
     getTable: state => state.table,
     getAccess: state => state.access,
-    getLocale: state => state.locale
+    getLocale: state => state.locale,
+    getMessages: state => state.messages
   },
   mutations: {
     SET_TOKEN (state, token) {
@@ -52,6 +55,9 @@ const store = new Store({
     },
     SET_LOCALE (state, locale) {
       state.locale = locale
+    },
+    SET_MESSAGES (state, messages) {
+      state.messages = messages
     }
   },
   actions: {
@@ -78,6 +84,12 @@ const store = new Store({
         .then((table) => {
           commit('SET_TABLE', table)
         })
+    },
+    reloadMessages ({ state, commit }) {
+      MetadataService.findActiveMessages()
+        .then((messages) => {
+          commit('SET_MESSAGES', messages)
+        })
     }
   }
 })
diff --git a/dbrepo-ui/utils/index.js b/dbrepo-ui/utils/index.js
index 01ed03767c..0afe772e10 100644
--- a/dbrepo-ui/utils/index.js
+++ b/dbrepo-ui/utils/index.js
@@ -99,6 +99,25 @@ function isOrcid (orcid) {
   return orcid.charAt(orcid.length - 1) === checksum
 }
 
+function isActiveMessage (message) {
+  if (!message) {
+    return false
+  }
+  if (message.display_start === null || message.display_end === null) {
+    return true
+  }
+  if (message.display_start === null || new Date(message.display_end) >= new Date()) {
+    return true
+  }
+  if (new Date(message.display_start) <= new Date() || new Date(message.display_end) >= new Date()) {
+    return true
+  }
+  if (new Date(message.display_start) <= new Date() || message.display_end === null) {
+    return true
+  }
+  return false
+}
+
 module.exports = {
   notEmpty,
   formatTimestamp,
@@ -109,5 +128,6 @@ module.exports = {
   formatYearUTC,
   formatMonthUTC,
   formatDayUTC,
-  isOrcid
+  isOrcid,
+  isActiveMessage
 }
diff --git a/dbrepo-user-service/rest-service/src/main/java/at/tuwien/endpoint/MaintenanceEndpoint.java b/dbrepo-user-service/rest-service/src/main/java/at/tuwien/endpoint/MaintenanceEndpoint.java
new file mode 100644
index 0000000000..8882f83fc9
--- /dev/null
+++ b/dbrepo-user-service/rest-service/src/main/java/at/tuwien/endpoint/MaintenanceEndpoint.java
@@ -0,0 +1,161 @@
+package at.tuwien.endpoint;
+
+import at.tuwien.api.maintenance.BannerMessageBriefDto;
+import at.tuwien.api.maintenance.BannerMessageCreateDto;
+import at.tuwien.api.maintenance.BannerMessageDto;
+import at.tuwien.api.maintenance.BannerMessageUpdateDto;
+import at.tuwien.api.user.UserBriefDto;
+import at.tuwien.exception.BannerMessageNotFoundException;
+import at.tuwien.mapper.BannerMessageMapper;
+import at.tuwien.service.BannerMessageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Log4j2
+@CrossOrigin(origins = "*")
+@RestController
+@RequestMapping("/api/maintenance")
+public class MaintenanceEndpoint {
+
+    private final BannerMessageMapper bannerMessageMapper;
+    private final BannerMessageService bannerMessageService;
+
+    @Autowired
+    public MaintenanceEndpoint(BannerMessageMapper bannerMessageMapper, BannerMessageService bannerMessageService) {
+        this.bannerMessageMapper = bannerMessageMapper;
+        this.bannerMessageService = bannerMessageService;
+    }
+
+    @GetMapping("/message")
+    @Operation(summary = "Find maintenance messages")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200",
+                    description = "List messages",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageDto[].class))}),
+    })
+    public ResponseEntity<List<BannerMessageDto>> list() {
+        log.debug("endpoint list active maintenance messages");
+        final List<BannerMessageDto> dtos = bannerMessageService.findAll()
+                .stream()
+                .map(bannerMessageMapper::bannerMessageToBannerMessageDto)
+                .toList();
+        log.trace("list maintenance messages results in dtos {}", dtos);
+        return ResponseEntity.ok(dtos);
+    }
+
+    @GetMapping("/message/{id}")
+    @Operation(summary = "Find one maintenance message")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200",
+                    description = "Get messages",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageDto.class))}),
+    })
+    public ResponseEntity<BannerMessageDto> find(@NotNull @PathVariable("id") Long messageId)
+            throws BannerMessageNotFoundException {
+        log.debug("endpoint find one maintenance messages");
+        final BannerMessageDto dto = bannerMessageMapper.bannerMessageToBannerMessageDto(bannerMessageService.find(messageId));
+        log.trace("find one maintenance message results in dto {}", dto);
+        return ResponseEntity.ok(dto);
+    }
+
+    @GetMapping("/message/active")
+    @Operation(summary = "Find active maintenance messages")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200",
+                    description = "List messages",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageBriefDto[].class))}),
+    })
+    public ResponseEntity<List<BannerMessageBriefDto>> active() {
+        log.debug("endpoint list active maintenance messages");
+        final List<BannerMessageBriefDto> dtos = bannerMessageService.getActive()
+                .stream()
+                .map(bannerMessageMapper::bannerMessageToBannerMessageBriefDto)
+                .toList();
+        log.trace("list active maintenance messages results in dtos {}", dtos);
+        return ResponseEntity.ok(dtos);
+    }
+
+    @PostMapping("/message")
+    @Operation(summary = "Create maintenance message")
+    @PreAuthorize("hasAuthority('create-maintenance-message')")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "201",
+                    description = "Created message",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageBriefDto.class))}),
+    })
+    public ResponseEntity<BannerMessageBriefDto> create(@Valid @RequestBody BannerMessageCreateDto data) {
+        log.debug("endpoint create maintenance message, data={}", data);
+        final BannerMessageBriefDto dto = bannerMessageMapper.bannerMessageToBannerMessageBriefDto(bannerMessageService.create(data));
+        log.trace("create maintenance message results in dto {}", dto);
+        return ResponseEntity.status(HttpStatus.CREATED)
+                .body(dto);
+    }
+
+    @PutMapping("/message/{id}")
+    @Operation(summary = "Update maintenance message")
+    @PreAuthorize("hasAuthority('update-maintenance-message')")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "202",
+                    description = "Updated message",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageBriefDto.class))}),
+            @ApiResponse(responseCode = "404",
+                    description = "Could not find message",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageNotFoundException.class))}),
+    })
+    public ResponseEntity<BannerMessageBriefDto> update(@NotNull @PathVariable("id") Long messageId,
+                                                        @Valid @RequestBody BannerMessageUpdateDto data)
+            throws BannerMessageNotFoundException {
+        log.debug("endpoint update maintenance message, messageId={}, data={}", messageId, data);
+        final BannerMessageBriefDto dto = bannerMessageMapper.bannerMessageToBannerMessageBriefDto(bannerMessageService.update(messageId, data));
+        log.trace("update maintenance message results in dto {}", dto);
+        return ResponseEntity.status(HttpStatus.ACCEPTED)
+                .body(dto);
+    }
+
+    @DeleteMapping("/message/{id}")
+    @Operation(summary = "Delete maintenance message")
+    @PreAuthorize("hasAuthority('delete-maintenance-message')")
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "202",
+                    description = "Deleted message",
+                    content = {@Content(mediaType = "application/json")}),
+            @ApiResponse(responseCode = "404",
+                    description = "Could not find message",
+                    content = {@Content(
+                            mediaType = "application/json",
+                            schema = @Schema(implementation = BannerMessageNotFoundException.class))}),
+    })
+    public ResponseEntity<BannerMessageBriefDto> delete(@NotNull @PathVariable("id") Long messageId)
+            throws BannerMessageNotFoundException {
+        log.debug("endpoint delete maintenance message, messageId={}", messageId);
+        bannerMessageService.delete(messageId);
+        return ResponseEntity.status(HttpStatus.ACCEPTED)
+                .build();
+    }
+
+}
diff --git a/dbrepo-user-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java b/dbrepo-user-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
index 3f1cf7c1ce..fe44809a44 100644
--- a/dbrepo-user-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
+++ b/dbrepo-user-service/services/src/main/java/at/tuwien/config/WebSecurityConfig.java
@@ -43,6 +43,7 @@ public class WebSecurityConfig {
         final OrRequestMatcher publicEndpoints = new OrRequestMatcher(
                 new AntPathRequestMatcher("/api/user/**", "GET"),
                 new AntPathRequestMatcher("/api/user/**", "POST"),
+                new AntPathRequestMatcher("/api/maintenance/**", "GET"),
                 new AntPathRequestMatcher("/v3/api-docs.yaml"),
                 new AntPathRequestMatcher("/v3/api-docs/**"),
                 new AntPathRequestMatcher("/swagger-ui/**"),
diff --git a/dbrepo-user-service/services/src/main/java/at/tuwien/exception/BannerMessageNotFoundException.java b/dbrepo-user-service/services/src/main/java/at/tuwien/exception/BannerMessageNotFoundException.java
new file mode 100644
index 0000000000..e4587b1453
--- /dev/null
+++ b/dbrepo-user-service/services/src/main/java/at/tuwien/exception/BannerMessageNotFoundException.java
@@ -0,0 +1,20 @@
+package at.tuwien.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(code = HttpStatus.NOT_FOUND)
+public class BannerMessageNotFoundException extends Exception {
+
+    public BannerMessageNotFoundException(String msg) {
+        super(msg);
+    }
+
+    public BannerMessageNotFoundException(String msg, Throwable thr) {
+        super(msg, thr);
+    }
+
+    public BannerMessageNotFoundException(Throwable thr) {
+        super(thr);
+    }
+}
diff --git a/dbrepo-user-service/services/src/main/java/at/tuwien/mapper/BannerMessageMapper.java b/dbrepo-user-service/services/src/main/java/at/tuwien/mapper/BannerMessageMapper.java
new file mode 100644
index 0000000000..8fd2b7dc51
--- /dev/null
+++ b/dbrepo-user-service/services/src/main/java/at/tuwien/mapper/BannerMessageMapper.java
@@ -0,0 +1,24 @@
+package at.tuwien.mapper;
+
+import at.tuwien.api.maintenance.BannerMessageBriefDto;
+import at.tuwien.api.maintenance.BannerMessageCreateDto;
+import at.tuwien.api.maintenance.BannerMessageDto;
+import at.tuwien.api.maintenance.BannerMessageTypeDto;
+import at.tuwien.entities.maintenance.BannerMessage;
+import at.tuwien.entities.maintenance.BannerMessageType;
+import org.mapstruct.Mapper;
+
+@Mapper(componentModel = "spring")
+public interface BannerMessageMapper {
+
+    org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(BannerMessageMapper.class);
+
+    BannerMessageDto bannerMessageToBannerMessageDto(BannerMessage data);
+
+    BannerMessageBriefDto bannerMessageToBannerMessageBriefDto(BannerMessage data);
+
+    BannerMessage bannerMessageCreateDtoToBannerMessage(BannerMessageCreateDto data);
+
+    BannerMessageType bannerMessageTypeDtoToBannerMessageType(BannerMessageTypeDto data);
+
+}
diff --git a/dbrepo-user-service/services/src/main/java/at/tuwien/repository/jpa/BannerMessageRepository.java b/dbrepo-user-service/services/src/main/java/at/tuwien/repository/jpa/BannerMessageRepository.java
new file mode 100644
index 0000000000..bcdbf9f832
--- /dev/null
+++ b/dbrepo-user-service/services/src/main/java/at/tuwien/repository/jpa/BannerMessageRepository.java
@@ -0,0 +1,14 @@
+package at.tuwien.repository.jpa;
+
+import at.tuwien.entities.maintenance.BannerMessage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface BannerMessageRepository extends JpaRepository<BannerMessage, Long> {
+
+    List<BannerMessage> findByActive();
+
+}
diff --git a/dbrepo-user-service/services/src/main/java/at/tuwien/service/BannerMessageService.java b/dbrepo-user-service/services/src/main/java/at/tuwien/service/BannerMessageService.java
new file mode 100644
index 0000000000..1753779848
--- /dev/null
+++ b/dbrepo-user-service/services/src/main/java/at/tuwien/service/BannerMessageService.java
@@ -0,0 +1,22 @@
+package at.tuwien.service;
+
+import at.tuwien.api.maintenance.BannerMessageCreateDto;
+import at.tuwien.api.maintenance.BannerMessageUpdateDto;
+import at.tuwien.entities.maintenance.BannerMessage;
+import at.tuwien.exception.BannerMessageNotFoundException;
+
+import java.util.List;
+
+public interface BannerMessageService {
+    List<BannerMessage> findAll();
+
+    List<BannerMessage> getActive();
+
+    BannerMessage find(Long id) throws BannerMessageNotFoundException;
+
+    BannerMessage create(BannerMessageCreateDto data);
+
+    BannerMessage update(Long id, BannerMessageUpdateDto data) throws BannerMessageNotFoundException;
+
+    void delete(Long id) throws BannerMessageNotFoundException;
+}
diff --git a/dbrepo-user-service/services/src/main/java/at/tuwien/service/impl/BannerMessageServiceImpl.java b/dbrepo-user-service/services/src/main/java/at/tuwien/service/impl/BannerMessageServiceImpl.java
new file mode 100644
index 0000000000..5c38de25fc
--- /dev/null
+++ b/dbrepo-user-service/services/src/main/java/at/tuwien/service/impl/BannerMessageServiceImpl.java
@@ -0,0 +1,78 @@
+package at.tuwien.service.impl;
+
+import at.tuwien.api.maintenance.BannerMessageCreateDto;
+import at.tuwien.api.maintenance.BannerMessageUpdateDto;
+import at.tuwien.entities.maintenance.BannerMessage;
+import at.tuwien.exception.BannerMessageNotFoundException;
+import at.tuwien.mapper.BannerMessageMapper;
+import at.tuwien.repository.jpa.BannerMessageRepository;
+import at.tuwien.service.BannerMessageService;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+@Log4j2
+@Service
+public class BannerMessageServiceImpl implements BannerMessageService {
+
+    private final BannerMessageMapper bannerMessageMapper;
+    private final BannerMessageRepository bannerMessageRepository;
+
+    @Autowired
+    public BannerMessageServiceImpl(BannerMessageMapper bannerMessageMapper,
+                                    BannerMessageRepository bannerMessageRepository) {
+        this.bannerMessageMapper = bannerMessageMapper;
+        this.bannerMessageRepository = bannerMessageRepository;
+    }
+
+    @Override
+    public List<BannerMessage> findAll() {
+        return bannerMessageRepository.findAll();
+    }
+
+    @Override
+    public List<BannerMessage> getActive() {
+        return bannerMessageRepository.findByActive();
+    }
+
+    @Override
+    public BannerMessage find(Long id) throws BannerMessageNotFoundException {
+        final Optional<BannerMessage> optional = bannerMessageRepository.findById(id);
+        if (optional.isEmpty()) {
+            log.error("Failed to find banner message with id {}", id);
+            throw new BannerMessageNotFoundException("Failed to find banner message with id " + id);
+        }
+        return optional.get();
+    }
+
+    @Override
+    public BannerMessage create(BannerMessageCreateDto data) {
+        final BannerMessage entity = bannerMessageMapper.bannerMessageCreateDtoToBannerMessage(data);
+        final BannerMessage message = bannerMessageRepository.save(entity);
+        log.info("Created banner message with id {}", message.getId());
+        return message;
+    }
+
+    @Override
+    public BannerMessage update(Long id, BannerMessageUpdateDto data) throws BannerMessageNotFoundException {
+        final BannerMessage entity = find(id);
+        entity.setMessage(data.getMessage());
+        entity.setDisplayEnd(data.getDisplayEnd());
+        entity.setDisplayStart(data.getDisplayStart());
+        entity.setType(bannerMessageMapper.bannerMessageTypeDtoToBannerMessageType(data.getType()));
+        final BannerMessage message = bannerMessageRepository.save(entity);
+        log.info("Updated banner message with id {}", message.getId());
+        return message;
+    }
+
+    @Override
+    public void delete(Long id) throws BannerMessageNotFoundException {
+        find(id);
+        bannerMessageRepository.deleteById(id);
+        log.info("Deleted banner message with id {}", id);
+    }
+
+}
diff --git a/dbrepo.conf b/dbrepo.conf
index e972bfcd58..4220e14b68 100644
--- a/dbrepo.conf
+++ b/dbrepo.conf
@@ -105,6 +105,15 @@ server {
         proxy_read_timeout      90;
     }
 
+    location /api/maintenance {
+        proxy_set_header        Host $host;
+        proxy_set_header        X-Real-IP $remote_addr;
+        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header        X-Forwarded-Proto $scheme;
+        proxy_pass              http://user;
+        proxy_read_timeout      90;
+    }
+
     location /api/identifier {
         proxy_set_header        Host $host;
         proxy_set_header        X-Real-IP $remote_addr;
-- 
GitLab