From 42745c9e3468cdef1762a4e5a400f2156bd528b7 Mon Sep 17 00:00:00 2001 From: Florian Scholdei Date: Thu, 20 May 2021 08:30:20 +0200 Subject: [PATCH] Notifications for health checks (#1664) Add list of emergency contacts to global configuration. This user will receive e-mails and notification if some serious system error occurs like repository health check failed. --- docs/de/user/admin/settings.md | 3 + docs/en/user/admin/settings.md | 3 + .../changelog/health_check_notifications.yaml | 2 + .../sonia/scm/config/ScmConfiguration.java | 21 + scm-ui/ui-api/src/config.test.ts | 1 + scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-api/src/userSuggestions.ts | 52 +++ .../AutocompleteAddEntryToTableField.tsx | 2 +- scm-ui/ui-types/src/Config.ts | 1 + .../ui-webapp/public/locales/de/config.json | 8 +- .../ui-webapp/public/locales/en/config.json | 8 +- .../src/admin/components/form/ConfigForm.tsx | 304 +++++++------- .../admin/components/form/GeneralSettings.tsx | 372 ++++++++++-------- .../form/NamespaceStrategySelect.tsx | 2 +- .../src/groups/containers/CreateGroup.tsx | 33 +- .../src/groups/containers/EditGroup.tsx | 34 +- .../sonia/scm/api/v2/resources/ConfigDto.java | 1 + .../scm/api/v2/resources/UpdateConfigDto.java | 2 + .../sonia/scm/repository/HealthChecker.java | 49 ++- .../main/resources/locales/de/plugins.json | 3 +- .../main/resources/locales/en/plugins.json | 3 +- ...ConfigDtoToScmConfigurationMapperTest.java | 3 + ...ScmConfigurationToConfigDtoMapperTest.java | 9 +- .../scm/repository/HealthCheckerTest.java | 39 +- 24 files changed, 551 insertions(+), 405 deletions(-) create mode 100644 gradle/changelog/health_check_notifications.yaml create mode 100644 scm-ui/ui-api/src/userSuggestions.ts diff --git a/docs/de/user/admin/settings.md b/docs/de/user/admin/settings.md index f89180e4b5..452780e96d 100644 --- a/docs/de/user/admin/settings.md +++ b/docs/de/user/admin/settings.md @@ -41,6 +41,9 @@ Ist der Benutzer Konverter aktiviert, werden alle internen Benutzer beim Einlogg #### Fallback E-Mail Domain Name Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut. +#### Notfallkontakte +Die folgenden Benutzer werden über administrative Vorfälle informiert (z. B. fehlgeschlagene Integritätsprüfungen). + #### Anmeldeversuche Es lässt sich konfigurieren wie häufig sich ein Benutzer falsch anmelden darf, bevor dessen Benutzerkonto gesperrt wird. Der Zähler für fehlerhafte Anmeldeversuche wird nach einem erfolgreichen Login zurückgesetzt. Man kann dieses Feature abschalten, indem man "-1" in die Konfiguration einträgt. diff --git a/docs/en/user/admin/settings.md b/docs/en/user/admin/settings.md index 527716f113..d49e89b6b1 100644 --- a/docs/en/user/admin/settings.md +++ b/docs/en/user/admin/settings.md @@ -41,6 +41,9 @@ Internal users will automatically be converted to external on their first login #### Fallback Mail Domain Name This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise. +#### Emergency Contacts +The following users will be notified of administrative incidents (e.g. failed health checks). + #### Login Attempt Limit It can be configured how many failed login attempts a user can have before the account gets disabled. The counter for failed login attempts is reset after a successful login. This feature can be deactivated by setting the value "-1". diff --git a/gradle/changelog/health_check_notifications.yaml b/gradle/changelog/health_check_notifications.yaml new file mode 100644 index 0000000000..c24a34e63c --- /dev/null +++ b/gradle/changelog/health_check_notifications.yaml @@ -0,0 +1,2 @@ +- type: added + description: Notifications for health checks ([#1664](https://github.com/scm-manager/scm-manager/pull/1664)) diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index f5e0754bbb..dfc448032f 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -214,6 +214,14 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "mail-domain-name") private String mailDomainName = DEFAULT_MAIL_DOMAIN_NAME; + /** + * List of users that will be notified of administrative incidents. + * + * @since 2.19.0 + */ + @XmlElement(name = "emergency-contacts") + private Set emergencyContacts; + /** * Fires the {@link ScmConfigurationChangedEvent}. */ @@ -253,6 +261,7 @@ public class ScmConfiguration implements Configuration { this.loginInfoUrl = other.loginInfoUrl; this.releaseFeedUrl = other.releaseFeedUrl; this.mailDomainName = other.mailDomainName; + this.emergencyContacts = other.emergencyContacts; this.enabledUserConverter = other.enabledUserConverter; this.enabledApiKeys = other.enabledApiKeys; } @@ -456,6 +465,14 @@ public class ScmConfiguration implements Configuration { return skipFailedAuthenticators; } + public Set getEmergencyContacts() { + if (emergencyContacts == null) { + emergencyContacts = Sets.newHashSet(); + } + + return emergencyContacts; + } + /** * Enables the anonymous access at protocol level. * @@ -621,6 +638,10 @@ public class ScmConfiguration implements Configuration { this.loginInfoUrl = loginInfoUrl; } + public void setEmergencyContacts(Set emergencyContacts) { + this.emergencyContacts = emergencyContacts; + } + @Override // Only for permission checks, don't serialize to XML @XmlTransient diff --git a/scm-ui/ui-api/src/config.test.ts b/scm-ui/ui-api/src/config.test.ts index 9ba7376238..8b357b8354 100644 --- a/scm-ui/ui-api/src/config.test.ts +++ b/scm-ui/ui-api/src/config.test.ts @@ -48,6 +48,7 @@ describe("Test config hooks", () => { loginInfoUrl: "", mailDomainName: "", namespaceStrategy: "", + emergencyContacts: [], pluginUrl: "", proxyExcludes: [], proxyPassword: null, diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 974bd58233..f3c3b420f5 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -32,6 +32,7 @@ export * from "./base"; export * from "./login"; export * from "./groups"; export * from "./users"; +export * from "./userSuggestions"; export * from "./repositories"; export * from "./namespaces"; export * from "./branches"; diff --git a/scm-ui/ui-api/src/userSuggestions.ts b/scm-ui/ui-api/src/userSuggestions.ts new file mode 100644 index 0000000000..f2e7221add --- /dev/null +++ b/scm-ui/ui-api/src/userSuggestions.ts @@ -0,0 +1,52 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import {DisplayedUser, Link, SelectValue} from "@scm-manager/ui-types"; +import { useIndexLinks } from "./base"; +import { apiClient } from "./apiclient"; + +export const useUserSuggestions = () => { + const indexLinks = useIndexLinks(); + const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users"); + if (!autocompleteLink) { + return []; + } + const url = autocompleteLink.href + "?q="; + return (inputValue: string): never[] | Promise => { + // Prevent violate input condition of api call because parameter length is too short + if (inputValue.length < 2) { + return []; + } + return apiClient + .get(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map((element: DisplayedUser) => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; +}; diff --git a/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx b/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx index 70366a4716..48666dc0a5 100644 --- a/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx +++ b/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx @@ -32,7 +32,7 @@ type Props = { addEntry: (p: SelectValue) => void; disabled?: boolean; buttonLabel: string; - fieldLabel: string; + fieldLabel?: string; helpText?: string; loadSuggestions: (p: string) => Promise; placeholder?: string; diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index ede3fd1558..808c8e7a59 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -50,5 +50,6 @@ export type Config = HalRepresentation & { loginInfoUrl: string; releaseFeedUrl: string; mailDomainName: string; + emergencyContacts: string[]; enabledApiKeys: boolean; }; diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 32605873b3..cf0799becc 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -61,7 +61,13 @@ "enabled-user-converter": "Benutzer Konverter aktivieren", "enabled-api-keys": "API Schlüssel aktivieren", "namespace-strategy": "Namespace Strategie", - "login-info-url": "Login Info URL" + "login-info-url": "Login Info URL", + "emergencyContacts": { + "label": "Notfallkontakte", + "helpText": "Liste der Benutzer, die über administrative Vorfälle informiert werden.", + "addButton": "Kontakt hinzufügen", + "autocompletePlaceholder": "Nutzer zum Benachrichtigen hinzufügen" + } }, "validation": { "date-format-invalid": "Das Datumsformat ist ungültig", diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 032a4bef6c..47722eab64 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -61,7 +61,13 @@ "enabled-user-converter": "Enabled User Converter", "enabled-api-keys": "Enabled API Keys", "namespace-strategy": "Namespace Strategy", - "login-info-url": "Login Info URL" + "login-info-url": "Login Info URL", + "emergencyContacts": { + "label": "Emergency Contacts", + "helpText": "List of users notified of administrative incidents.", + "addButton": "Add Contact", + "autocompletePlaceholder": "Add User to Notify" + } }, "validation": { "date-format-invalid": "The date format is not valid", diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index f2d0f92068..db3df04dfa 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -21,8 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC, useState, useEffect, FormEvent } from "react"; +import { useTranslation } from "react-i18next"; import { Config, NamespaceStrategies } from "@scm-manager/ui-types"; import { Level, Notification, SubmitButton } from "@scm-manager/ui-components"; import ProxySettings from "./ProxySettings"; @@ -30,7 +30,7 @@ import GeneralSettings from "./GeneralSettings"; import BaseUrlSettings from "./BaseUrlSettings"; import LoginAttempt from "./LoginAttempt"; -type Props = WithTranslation & { +type Props = { submitForm: (p: Config) => void; config?: Config; loading?: boolean; @@ -39,185 +39,157 @@ type Props = WithTranslation & { namespaceStrategies?: NamespaceStrategies; }; -type State = { - config: Config; - showNotification: boolean; - error: { +const ConfigForm: FC = ({ + submitForm, + config, + loading, + configReadPermission, + configUpdatePermission, + namespaceStrategies +}) => { + const [t] = useTranslation("config"); + const [innerConfig, setInnerConfig] = useState({ + proxyPassword: null, + proxyPort: 0, + proxyServer: "", + proxyUser: null, + enableProxy: false, + realmDescription: "", + disableGroupingGrid: false, + dateFormat: "", + anonymousAccessEnabled: false, + anonymousMode: "OFF", + baseUrl: "", + forceBaseUrl: false, + loginAttemptLimit: 0, + proxyExcludes: [], + skipFailedAuthenticators: false, + pluginUrl: "", + loginAttemptLimitTimeout: 0, + enabledXsrfProtection: true, + enabledUserConverter: false, + namespaceStrategy: "", + loginInfoUrl: "", + releaseFeedUrl: "", + mailDomainName: "", + emergencyContacts: [], + enabledApiKeys: true, + _links: {} + }); + const [showNotification, setShowNotification] = useState(false); + const [changed, setChanged] = useState(false); + const [error, setError] = useState<{ loginAttemptLimitTimeout: boolean; loginAttemptLimit: boolean; - }; - changed: boolean; -}; + }>({ + loginAttemptLimitTimeout: false, + loginAttemptLimit: false + }); -class ConfigForm extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - config: { - proxyPassword: null, - proxyPort: 0, - proxyServer: "", - proxyUser: null, - enableProxy: false, - realmDescription: "", - disableGroupingGrid: false, - dateFormat: "", - anonymousMode: "OFF", - baseUrl: "", - mailDomainName: "", - forceBaseUrl: false, - loginAttemptLimit: 0, - proxyExcludes: [], - skipFailedAuthenticators: false, - pluginUrl: "", - loginAttemptLimitTimeout: 0, - enabledXsrfProtection: true, - enabledUserConverter: false, - namespaceStrategy: "", - loginInfoUrl: "", - _links: {} - }, - showNotification: false, - error: { - loginAttemptLimitTimeout: false, - loginAttemptLimit: false - }, - changed: false - }; - } - - componentDidMount() { - const { config, configUpdatePermission } = this.props; + useEffect(() => { if (config) { - this.setState({ - ...this.state, - config: { - ...config - } - }); + setInnerConfig(config); } if (!configUpdatePermission) { - this.setState({ - ...this.state, - showNotification: true - }); + setShowNotification(true); } - } + }, [config, configUpdatePermission]); - submit = (event: Event) => { + const submit = (event: FormEvent) => { event.preventDefault(); - this.setState({ - changed: false - }); - this.props.submitForm(this.state.config); + setChanged(false); + submitForm(innerConfig); }; - render() { - const { loading, t, namespaceStrategies, configReadPermission, configUpdatePermission } = this.props; - const config = this.state.config; + const onChange = (isValid: boolean, changedValue: any, name: string) => { + setInnerConfig({ ...innerConfig, [name]: changedValue }); + setError({ ...error, [name]: !isValid }); + setChanged(true); + }; - let noPermissionNotification = null; + const hasError = () => { + return error.loginAttemptLimit || error.loginAttemptLimitTimeout; + }; - if (!configReadPermission) { - return ; - } + const onClose = () => { + setShowNotification(false); + }; - if (this.state.showNotification) { - noPermissionNotification = ( - this.onClose()} - /> - ); - } + let noPermissionNotification = null; - return ( -
- {noPermissionNotification} - this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- - } - /> - + if (!configReadPermission) { + return ; + } + + if (showNotification) { + noPermissionNotification = ( + onClose()} + /> ); } - onChange = (isValid: boolean, changedValue: any, name: string) => { - this.setState({ - ...this.state, - config: { - ...this.state.config, - [name]: changedValue - }, - error: { - ...this.state.error, - [name]: !isValid - }, - changed: true - }); - }; + return ( +
+ {noPermissionNotification} + onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ + } + /> + + ); +}; - hasError = () => { - return this.state.error.loginAttemptLimit || this.state.error.loginAttemptLimitTimeout; - }; - - onClose = () => { - this.setState({ - ...this.state, - showNotification: false - }); - }; -} - -export default withTranslation("config")(ConfigForm); +export default ConfigForm; diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index 390a8726a7..ccdad61c83 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -21,13 +21,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; -import { Checkbox, InputField, Select } from "@scm-manager/ui-components"; -import { NamespaceStrategies, AnonymousMode } from "@scm-manager/ui-types"; +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useUserSuggestions } from "@scm-manager/ui-api"; +import { NamespaceStrategies, AnonymousMode, SelectValue } from "@scm-manager/ui-types"; +import { + Checkbox, + InputField, + MemberNameTagGroup, + AutocompleteAddEntryToTableField, + Select +} from "@scm-manager/ui-components"; import NamespaceStrategySelect from "./NamespaceStrategySelect"; -type Props = WithTranslation & { +type Props = { realmDescription: string; loginInfoUrl: string; disableGroupingGrid: boolean; @@ -40,176 +47,207 @@ type Props = WithTranslation & { enabledXsrfProtection: boolean; enabledUserConverter: boolean; enabledApiKeys: boolean; + emergencyContacts: string[]; namespaceStrategy: string; namespaceStrategies?: NamespaceStrategies; onChange: (p1: boolean, p2: any, p3: string) => void; hasUpdatePermission: boolean; }; -class GeneralSettings extends React.Component { - render() { - const { - t, - realmDescription, - loginInfoUrl, - pluginUrl, - releaseFeedUrl, - mailDomainName, - enabledXsrfProtection, - enabledUserConverter, - enabledApiKeys, - anonymousMode, - namespaceStrategy, - hasUpdatePermission, - namespaceStrategies - } = this.props; +const GeneralSettings: FC = ({ + realmDescription, + loginInfoUrl, + anonymousMode, + pluginUrl, + releaseFeedUrl, + mailDomainName, + enabledXsrfProtection, + enabledUserConverter, + enabledApiKeys, + emergencyContacts, + namespaceStrategy, + namespaceStrategies, + onChange, + hasUpdatePermission +}) => { + const { t } = useTranslation("config"); + const userSuggestions = useUserSuggestions(); - return ( -
-
-
- -
-
- -
+ const handleLoginInfoUrlChange = (value: string) => { + onChange(true, value, "loginInfoUrl"); + }; + const handleRealmDescriptionChange = (value: string) => { + onChange(true, value, "realmDescription"); + }; + const handleEnabledXsrfProtectionChange = (value: boolean) => { + onChange(true, value, "enabledXsrfProtection"); + }; + const handleEnabledUserConverterChange = (value: boolean) => { + onChange(true, value, "enabledUserConverter"); + }; + const handleAnonymousMode = (value: string) => { + onChange(true, value, "anonymousMode"); + }; + const handleNamespaceStrategyChange = (value: string) => { + onChange(true, value, "namespaceStrategy"); + }; + const handlePluginCenterUrlChange = (value: string) => { + onChange(true, value, "pluginUrl"); + }; + const handleReleaseFeedUrlChange = (value: string) => { + onChange(true, value, "releaseFeedUrl"); + }; + const handleMailDomainNameChange = (value: string) => { + onChange(true, value, "mailDomainName"); + }; + const handleEnabledApiKeysChange = (value: boolean) => { + onChange(true, value, "enabledApiKeys"); + }; + const handleEmergencyContactsChange = (p: string[]) => { + onChange(true, p, "emergencyContacts"); + }; + + const isMember = (name: string) => { + return emergencyContacts.includes(name); + }; + + const addEmergencyContact = (value: SelectValue) => { + if (isMember(value.value.id)) { + return; + } + handleEmergencyContactsChange([...emergencyContacts, value.value.id]); + }; + + return ( +
+
+
+
-
-
- -
-
- -
-
-
-
- -
-
- +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+
+ ); +}; - handleLoginInfoUrlChange = (value: string) => { - this.props.onChange(true, value, "loginInfoUrl"); - }; - handleRealmDescriptionChange = (value: string) => { - this.props.onChange(true, value, "realmDescription"); - }; - handleEnabledXsrfProtectionChange = (value: boolean) => { - this.props.onChange(true, value, "enabledXsrfProtection"); - }; - handleEnabledUserConverterChange = (value: boolean) => { - this.props.onChange(true, value, "enabledUserConverter"); - }; - handleAnonymousMode = (value: string) => { - this.props.onChange(true, value, "anonymousMode"); - }; - handleNamespaceStrategyChange = (value: string) => { - this.props.onChange(true, value, "namespaceStrategy"); - }; - handlePluginCenterUrlChange = (value: string) => { - this.props.onChange(true, value, "pluginUrl"); - }; - handleReleaseFeedUrlChange = (value: string) => { - this.props.onChange(true, value, "releaseFeedUrl"); - }; - handleMailDomainNameChange = (value: string) => { - this.props.onChange(true, value, "mailDomainName"); - }; - handleEnabledApiKeysChange = (value: boolean) => { - this.props.onChange(true, value, "enabledApiKeys"); - }; -} - -export default withTranslation("config")(GeneralSettings); +export default GeneralSettings; diff --git a/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx b/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx index 847d6b6e8d..e5a17fa47a 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx @@ -27,7 +27,7 @@ import { NamespaceStrategies } from "@scm-manager/ui-types"; import { Select } from "@scm-manager/ui-components"; type Props = WithTranslation & { - namespaceStrategies: NamespaceStrategies; + namespaceStrategies?: NamespaceStrategies; label: string; value?: string; disabled?: boolean; diff --git a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx index a8a01f7bdb..5a7f3f521b 100644 --- a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx @@ -22,46 +22,25 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { useTranslation } from "react-i18next"; -import { DisplayedUser, Link } from "@scm-manager/ui-types"; -import { apiClient, Page } from "@scm-manager/ui-components"; -import GroupForm from "../components/GroupForm"; -import { useCreateGroup, useIndexLinks } from "@scm-manager/ui-api"; import { Redirect } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useCreateGroup, useUserSuggestions } from "@scm-manager/ui-api"; +import { Page } from "@scm-manager/ui-components"; +import GroupForm from "../components/GroupForm"; const CreateGroup: FC = () => { const [t] = useTranslation("groups"); const { isLoading, create, error, group } = useCreateGroup(); - const indexLinks = useIndexLinks(); + const userSuggestions = useUserSuggestions(); if (group) { return ; } - // TODO: Replace with react-query hook - const loadUserAutocompletion = (inputValue: string) => { - const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users"); - if (!autocompleteLink) { - return []; - } - const url = autocompleteLink.href + "?q="; - return apiClient - .get(url + inputValue) - .then(response => response.json()) - .then(json => { - return json.map((element: DisplayedUser) => { - return { - value: element, - label: `${element.displayName} (${element.id})` - }; - }); - }); - }; - return (
- +
); diff --git a/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx index 58d20c1116..e1ec8455b0 100644 --- a/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx @@ -22,49 +22,29 @@ * SOFTWARE. */ import React, { FC } from "react"; -import GroupForm from "../components/GroupForm"; -import { DisplayedUser, Group, Link } from "@scm-manager/ui-types"; -import { apiClient, ErrorNotification } from "@scm-manager/ui-components"; -import DeleteGroup from "./DeleteGroup"; -import { useIndexLinks, useUpdateGroup } from "@scm-manager/ui-api"; import { Redirect } from "react-router-dom"; +import { Group } from "@scm-manager/ui-types"; +import { useUpdateGroup, useUserSuggestions } from "@scm-manager/ui-api"; +import { ErrorNotification } from "@scm-manager/ui-components"; +import GroupForm from "../components/GroupForm"; +import DeleteGroup from "./DeleteGroup"; type Props = { group: Group; }; const EditGroup: FC = ({ group }) => { - const indexLinks = useIndexLinks(); const { error, isLoading, update, isUpdated } = useUpdateGroup(); - const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users"); + const userSuggestions = useUserSuggestions(); if (isUpdated) { return ; } - // TODO: Replace with react-query hook - const loadUserAutocompletion = (inputValue: string) => { - if (!autocompleteLink) { - return []; - } - const url = autocompleteLink.href + "?q="; - return apiClient - .get(url + inputValue) - .then(response => response.json()) - .then(json => { - return json.map((element: DisplayedUser) => { - return { - value: element, - label: `${element.displayName} (${element.id})` - }; - }); - }); - }; - return (
- +
); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index 6aa9a2a321..82c9bdb4a8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -62,6 +62,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto { private String loginInfoUrl; private String releaseFeedUrl; private String mailDomainName; + private Set emergencyContacts; @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java index dbe07a2620..f62c51db13 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java @@ -74,4 +74,6 @@ interface UpdateConfigDto { String getReleaseFeedUrl(); String getMailDomainName(); + + Set getEmergencyContacts(); } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java b/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java index 24339f0fbd..b3ea7a7552 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java @@ -25,9 +25,14 @@ package sonia.scm.repository; import com.google.inject.Inject; +import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.notifications.Notification; +import sonia.scm.notifications.NotificationSender; +import sonia.scm.notifications.Type; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -59,15 +64,22 @@ final class HealthChecker { private final ExecutorService healthCheckExecutor = Executors.newSingleThreadExecutor(); + private final ScmConfiguration scmConfiguration; + private final NotificationSender notificationSender; + @Inject HealthChecker(Set checks, RepositoryManager repositoryManager, RepositoryServiceFactory repositoryServiceFactory, - RepositoryPostProcessor repositoryPostProcessor) { + RepositoryPostProcessor repositoryPostProcessor, + ScmConfiguration scmConfiguration, + NotificationSender notificationSender) { this.checks = checks; this.repositoryManager = repositoryManager; this.repositoryServiceFactory = repositoryServiceFactory; this.repositoryPostProcessor = repositoryPostProcessor; + this.scmConfiguration = scmConfiguration; + this.notificationSender = notificationSender; } void lightCheck(String id) { @@ -155,13 +167,14 @@ final class HealthChecker { private void doFullCheck(Repository repository) { withLockedRepository(repository, () -> - runInExecutorAndWait(repository, () -> { - HealthCheckResult lightCheckResult = gatherLightChecks(repository); - HealthCheckResult fullCheckResult = gatherFullChecks(repository); - HealthCheckResult result = lightCheckResult.merge(fullCheckResult); + runInExecutorAndWait(repository, () -> { + HealthCheckResult lightCheckResult = gatherLightChecks(repository); + HealthCheckResult fullCheckResult = gatherFullChecks(repository); + HealthCheckResult result = lightCheckResult.merge(fullCheckResult); - storeResult(repository, result); - }) + notifyCurrentUser(repository, result); + storeResult(repository, result); + }) ); } @@ -204,10 +217,32 @@ final class HealthChecker { logger.trace("store health check results for repository {}", repository); repositoryPostProcessor.setCheckResults(repository, result.getFailures()); + + notifyEmergencyContacts(repository); } } public boolean checkRunning(String repositoryId) { return checksRunning.contains(repositoryId); } + + private void notifyCurrentUser(Repository repository, HealthCheckResult result) { + if (!(repository.isHealthy() && result.isHealthy())) { + String currentUser = SecurityUtils.getSubject().getPrincipal().toString(); + if (!scmConfiguration.getEmergencyContacts().contains(currentUser)) { + notificationSender.send(getHealthCheckFailedNotification(repository)); + } + } + } + + private void notifyEmergencyContacts(Repository repository) { + Set emergencyContacts = scmConfiguration.getEmergencyContacts(); + for (String user : emergencyContacts) { + notificationSender.send(getHealthCheckFailedNotification(repository), user); + } + } + + private Notification getHealthCheckFailedNotification(Repository repository) { + return new Notification(Type.ERROR, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "healthCheckFailed"); + } } diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index ccabef40ff..8ec998c15a 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -427,6 +427,7 @@ }, "notifications": { "exportFinished": "Der Repository Export wurde abgeschlossen.", - "exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator." + "exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator.", + "healthCheckFailed": "Der Repository Health Check ist fehlgeschlagen." } } diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 1ac1b8f043..723f26dee1 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -371,6 +371,7 @@ }, "notifications": { "exportFinished": "The repository export has been finished.", - "exportFailed": "The repository export has failed. Try it again or contact your administrator." + "exportFailed": "The repository export has failed. Try it again or contact your administrator.", + "healthCheckFailed": "The repository health check has failed." } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java index 632841b78f..acba81b3e8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java @@ -44,6 +44,7 @@ public class ConfigDtoToScmConfigurationMapperTest { private ConfigDtoToScmConfigurationMapperImpl mapper; private final String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedUsers = {"trillian", "arthur"}; @Before public void init() { @@ -76,6 +77,7 @@ public class ConfigDtoToScmConfigurationMapperTest { assertEquals("username", config.getNamespaceStrategy()); assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl()); assertEquals("hitchhiker.mail", config.getMailDomainName()); + assertTrue("emergencyContacts", config.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers))); } @Test @@ -115,6 +117,7 @@ public class ConfigDtoToScmConfigurationMapperTest { configDto.setNamespaceStrategy("username"); configDto.setLoginInfoUrl("https://scm-manager.org/login-info"); configDto.setMailDomainName("hitchhiker.mail"); + configDto.setEmergencyContacts(Sets.newSet(expectedUsers)); configDto.setEnabledUserConverter(false); return configDto; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java index 49769bf1b3..2c5b8608cc 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java @@ -49,11 +49,10 @@ import static org.mockito.MockitoAnnotations.initMocks; public class ScmConfigurationToConfigDtoMapperTest { - private URI baseUri = URI.create("http://example.com/base/"); + private final URI baseUri = URI.create("http://example.com/base/"); - private String[] expectedUsers = {"trillian", "arthur"}; - private String[] expectedGroups = {"admin", "plebs"}; - private String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedUsers = {"trillian", "arthur"}; @SuppressWarnings("unused") // Is injected private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @@ -107,6 +106,7 @@ public class ScmConfigurationToConfigDtoMapperTest { assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl()); assertEquals("https://www.scm-manager.org/download/rss.xml", dto.getReleaseFeedUrl()); assertEquals("scm-manager.local", dto.getMailDomainName()); + assertTrue("emergencyContacts", dto.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers))); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref()); @@ -161,6 +161,7 @@ public class ScmConfigurationToConfigDtoMapperTest { config.setNamespaceStrategy("username"); config.setLoginInfoUrl("https://scm-manager.org/login-info"); config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml"); + config.setEmergencyContacts(Sets.newSet(expectedUsers)); return config; } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java index c3cb9cb287..69bf829477 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java @@ -24,6 +24,7 @@ package sonia.scm.repository; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; @@ -35,6 +36,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.notifications.NotificationSender; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.FullHealthCheckCommandBuilder; import sonia.scm.repository.api.RepositoryService; @@ -47,11 +50,14 @@ import static com.google.common.collect.ImmutableSet.of; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -74,6 +80,10 @@ class HealthCheckerTest { private RepositoryService repositoryService; @Mock private RepositoryPostProcessor postProcessor; + @Mock + private ScmConfiguration scmConfiguration; + @Mock + private NotificationSender notificationSender; @Mock private Subject subject; @@ -82,7 +92,7 @@ class HealthCheckerTest { @BeforeEach void initializeChecker() { - this.checker = new HealthChecker(of(healthCheck1, healthCheck2), repositoryManager, repositoryServiceFactory, postProcessor); + this.checker = new HealthChecker(of(healthCheck1, healthCheck2), repositoryManager, repositoryServiceFactory, postProcessor, scmConfiguration, notificationSender); } @BeforeEach @@ -182,6 +192,7 @@ class HealthCheckerTest { void setUpRepository() { lenient().when(repositoryServiceFactory.create(repository)).thenReturn(repositoryService); lenient().when(repositoryService.getFullCheckCommand()).thenReturn(fullHealthCheckCommand); + lenient().when(subject.getPrincipal()).thenReturn("trillian"); } @Test @@ -240,6 +251,32 @@ class HealthCheckerTest { return true; })); } + + @Test + void shouldNotifyCurrentUserOnlyOnce() throws IOException { + when(scmConfiguration.getEmergencyContacts()).thenReturn(ImmutableSet.of("trillian")); + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(repositoryService.isSupported(Command.FULL_HEALTH_CHECK)).thenReturn(true); + when(fullHealthCheckCommand.check()).thenReturn(HealthCheckResult.unhealthy(createFailure("error"))); + + checker.fullCheck(repositoryId); + + verify(notificationSender, never()).send(any()); + verify(notificationSender,times(1)).send(any(), eq("trillian")); + } + + @Test + void shouldNotifyEmergencyContacts() throws IOException { + when(scmConfiguration.getEmergencyContacts()).thenReturn(ImmutableSet.of("trillian", "Arthur")); + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(repositoryService.isSupported(Command.FULL_HEALTH_CHECK)).thenReturn(true); + when(fullHealthCheckCommand.check()).thenReturn(HealthCheckResult.unhealthy(createFailure("error"))); + + checker.fullCheck(repositoryId); + + verify(notificationSender).send(any(), eq("trillian")); + verify(notificationSender).send(any(), eq("Arthur")); + } } }