mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-13 08:55:44 +01:00
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.
This commit is contained in:
@@ -41,6 +41,9 @@ Ist der Benutzer Konverter aktiviert, werden alle internen Benutzer beim Einlogg
|
|||||||
#### Fallback E-Mail Domain Name
|
#### 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.
|
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
|
#### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ Internal users will automatically be converted to external on their first login
|
|||||||
#### Fallback Mail Domain Name
|
#### 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.
|
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
|
#### 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".
|
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".
|
||||||
|
|
||||||
|
|||||||
2
gradle/changelog/health_check_notifications.yaml
Normal file
2
gradle/changelog/health_check_notifications.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: added
|
||||||
|
description: Notifications for health checks ([#1664](https://github.com/scm-manager/scm-manager/pull/1664))
|
||||||
@@ -214,6 +214,14 @@ public class ScmConfiguration implements Configuration {
|
|||||||
@XmlElement(name = "mail-domain-name")
|
@XmlElement(name = "mail-domain-name")
|
||||||
private String mailDomainName = DEFAULT_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<String> emergencyContacts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fires the {@link ScmConfigurationChangedEvent}.
|
* Fires the {@link ScmConfigurationChangedEvent}.
|
||||||
*/
|
*/
|
||||||
@@ -253,6 +261,7 @@ public class ScmConfiguration implements Configuration {
|
|||||||
this.loginInfoUrl = other.loginInfoUrl;
|
this.loginInfoUrl = other.loginInfoUrl;
|
||||||
this.releaseFeedUrl = other.releaseFeedUrl;
|
this.releaseFeedUrl = other.releaseFeedUrl;
|
||||||
this.mailDomainName = other.mailDomainName;
|
this.mailDomainName = other.mailDomainName;
|
||||||
|
this.emergencyContacts = other.emergencyContacts;
|
||||||
this.enabledUserConverter = other.enabledUserConverter;
|
this.enabledUserConverter = other.enabledUserConverter;
|
||||||
this.enabledApiKeys = other.enabledApiKeys;
|
this.enabledApiKeys = other.enabledApiKeys;
|
||||||
}
|
}
|
||||||
@@ -456,6 +465,14 @@ public class ScmConfiguration implements Configuration {
|
|||||||
return skipFailedAuthenticators;
|
return skipFailedAuthenticators;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<String> getEmergencyContacts() {
|
||||||
|
if (emergencyContacts == null) {
|
||||||
|
emergencyContacts = Sets.newHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
return emergencyContacts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables the anonymous access at protocol level.
|
* Enables the anonymous access at protocol level.
|
||||||
*
|
*
|
||||||
@@ -621,6 +638,10 @@ public class ScmConfiguration implements Configuration {
|
|||||||
this.loginInfoUrl = loginInfoUrl;
|
this.loginInfoUrl = loginInfoUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setEmergencyContacts(Set<String> emergencyContacts) {
|
||||||
|
this.emergencyContacts = emergencyContacts;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
// Only for permission checks, don't serialize to XML
|
// Only for permission checks, don't serialize to XML
|
||||||
@XmlTransient
|
@XmlTransient
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe("Test config hooks", () => {
|
|||||||
loginInfoUrl: "",
|
loginInfoUrl: "",
|
||||||
mailDomainName: "",
|
mailDomainName: "",
|
||||||
namespaceStrategy: "",
|
namespaceStrategy: "",
|
||||||
|
emergencyContacts: [],
|
||||||
pluginUrl: "",
|
pluginUrl: "",
|
||||||
proxyExcludes: [],
|
proxyExcludes: [],
|
||||||
proxyPassword: null,
|
proxyPassword: null,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export * from "./base";
|
|||||||
export * from "./login";
|
export * from "./login";
|
||||||
export * from "./groups";
|
export * from "./groups";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
export * from "./userSuggestions";
|
||||||
export * from "./repositories";
|
export * from "./repositories";
|
||||||
export * from "./namespaces";
|
export * from "./namespaces";
|
||||||
export * from "./branches";
|
export * from "./branches";
|
||||||
|
|||||||
52
scm-ui/ui-api/src/userSuggestions.ts
Normal file
52
scm-ui/ui-api/src/userSuggestions.ts
Normal file
@@ -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<SelectValue[]> => {
|
||||||
|
// 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})`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -32,7 +32,7 @@ type Props = {
|
|||||||
addEntry: (p: SelectValue) => void;
|
addEntry: (p: SelectValue) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
buttonLabel: string;
|
buttonLabel: string;
|
||||||
fieldLabel: string;
|
fieldLabel?: string;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
loadSuggestions: (p: string) => Promise<SelectValue[]>;
|
loadSuggestions: (p: string) => Promise<SelectValue[]>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|||||||
@@ -50,5 +50,6 @@ export type Config = HalRepresentation & {
|
|||||||
loginInfoUrl: string;
|
loginInfoUrl: string;
|
||||||
releaseFeedUrl: string;
|
releaseFeedUrl: string;
|
||||||
mailDomainName: string;
|
mailDomainName: string;
|
||||||
|
emergencyContacts: string[];
|
||||||
enabledApiKeys: boolean;
|
enabledApiKeys: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,7 +61,13 @@
|
|||||||
"enabled-user-converter": "Benutzer Konverter aktivieren",
|
"enabled-user-converter": "Benutzer Konverter aktivieren",
|
||||||
"enabled-api-keys": "API Schlüssel aktivieren",
|
"enabled-api-keys": "API Schlüssel aktivieren",
|
||||||
"namespace-strategy": "Namespace Strategie",
|
"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": {
|
"validation": {
|
||||||
"date-format-invalid": "Das Datumsformat ist ungültig",
|
"date-format-invalid": "Das Datumsformat ist ungültig",
|
||||||
|
|||||||
@@ -61,7 +61,13 @@
|
|||||||
"enabled-user-converter": "Enabled User Converter",
|
"enabled-user-converter": "Enabled User Converter",
|
||||||
"enabled-api-keys": "Enabled API Keys",
|
"enabled-api-keys": "Enabled API Keys",
|
||||||
"namespace-strategy": "Namespace Strategy",
|
"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": {
|
"validation": {
|
||||||
"date-format-invalid": "The date format is not valid",
|
"date-format-invalid": "The date format is not valid",
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useState, useEffect, FormEvent } from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Config, NamespaceStrategies } from "@scm-manager/ui-types";
|
import { Config, NamespaceStrategies } from "@scm-manager/ui-types";
|
||||||
import { Level, Notification, SubmitButton } from "@scm-manager/ui-components";
|
import { Level, Notification, SubmitButton } from "@scm-manager/ui-components";
|
||||||
import ProxySettings from "./ProxySettings";
|
import ProxySettings from "./ProxySettings";
|
||||||
@@ -30,7 +30,7 @@ import GeneralSettings from "./GeneralSettings";
|
|||||||
import BaseUrlSettings from "./BaseUrlSettings";
|
import BaseUrlSettings from "./BaseUrlSettings";
|
||||||
import LoginAttempt from "./LoginAttempt";
|
import LoginAttempt from "./LoginAttempt";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
submitForm: (p: Config) => void;
|
submitForm: (p: Config) => void;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -39,22 +39,16 @@ type Props = WithTranslation & {
|
|||||||
namespaceStrategies?: NamespaceStrategies;
|
namespaceStrategies?: NamespaceStrategies;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
const ConfigForm: FC<Props> = ({
|
||||||
config: Config;
|
submitForm,
|
||||||
showNotification: boolean;
|
config,
|
||||||
error: {
|
loading,
|
||||||
loginAttemptLimitTimeout: boolean;
|
configReadPermission,
|
||||||
loginAttemptLimit: boolean;
|
configUpdatePermission,
|
||||||
};
|
namespaceStrategies
|
||||||
changed: boolean;
|
}) => {
|
||||||
};
|
const [t] = useTranslation("config");
|
||||||
|
const [innerConfig, setInnerConfig] = useState<Config>({
|
||||||
class ConfigForm extends React.Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
config: {
|
|
||||||
proxyPassword: null,
|
proxyPassword: null,
|
||||||
proxyPort: 0,
|
proxyPort: 0,
|
||||||
proxyServer: "",
|
proxyServer: "",
|
||||||
@@ -63,9 +57,9 @@ class ConfigForm extends React.Component<Props, State> {
|
|||||||
realmDescription: "",
|
realmDescription: "",
|
||||||
disableGroupingGrid: false,
|
disableGroupingGrid: false,
|
||||||
dateFormat: "",
|
dateFormat: "",
|
||||||
|
anonymousAccessEnabled: false,
|
||||||
anonymousMode: "OFF",
|
anonymousMode: "OFF",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
mailDomainName: "",
|
|
||||||
forceBaseUrl: false,
|
forceBaseUrl: false,
|
||||||
loginAttemptLimit: 0,
|
loginAttemptLimit: 0,
|
||||||
proxyExcludes: [],
|
proxyExcludes: [],
|
||||||
@@ -76,46 +70,50 @@ class ConfigForm extends React.Component<Props, State> {
|
|||||||
enabledUserConverter: false,
|
enabledUserConverter: false,
|
||||||
namespaceStrategy: "",
|
namespaceStrategy: "",
|
||||||
loginInfoUrl: "",
|
loginInfoUrl: "",
|
||||||
|
releaseFeedUrl: "",
|
||||||
|
mailDomainName: "",
|
||||||
|
emergencyContacts: [],
|
||||||
|
enabledApiKeys: true,
|
||||||
_links: {}
|
_links: {}
|
||||||
},
|
});
|
||||||
showNotification: false,
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
error: {
|
const [changed, setChanged] = useState(false);
|
||||||
|
const [error, setError] = useState<{
|
||||||
|
loginAttemptLimitTimeout: boolean;
|
||||||
|
loginAttemptLimit: boolean;
|
||||||
|
}>({
|
||||||
loginAttemptLimitTimeout: false,
|
loginAttemptLimitTimeout: false,
|
||||||
loginAttemptLimit: false
|
loginAttemptLimit: false
|
||||||
},
|
|
||||||
changed: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { config, configUpdatePermission } = this.props;
|
|
||||||
if (config) {
|
|
||||||
this.setState({
|
|
||||||
...this.state,
|
|
||||||
config: {
|
|
||||||
...config
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setInnerConfig(config);
|
||||||
}
|
}
|
||||||
if (!configUpdatePermission) {
|
if (!configUpdatePermission) {
|
||||||
this.setState({
|
setShowNotification(true);
|
||||||
...this.state,
|
|
||||||
showNotification: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [config, configUpdatePermission]);
|
||||||
|
|
||||||
submit = (event: Event) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.setState({
|
setChanged(false);
|
||||||
changed: false
|
submitForm(innerConfig);
|
||||||
});
|
|
||||||
this.props.submitForm(this.state.config);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
const onChange = (isValid: boolean, changedValue: any, name: string) => {
|
||||||
const { loading, t, namespaceStrategies, configReadPermission, configUpdatePermission } = this.props;
|
setInnerConfig({ ...innerConfig, [name]: changedValue });
|
||||||
const config = this.state.config;
|
setError({ ...error, [name]: !isValid });
|
||||||
|
setChanged(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasError = () => {
|
||||||
|
return error.loginAttemptLimit || error.loginAttemptLimitTimeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
setShowNotification(false);
|
||||||
|
};
|
||||||
|
|
||||||
let noPermissionNotification = null;
|
let noPermissionNotification = null;
|
||||||
|
|
||||||
@@ -123,60 +121,61 @@ class ConfigForm extends React.Component<Props, State> {
|
|||||||
return <Notification type={"danger"} children={t("config.form.no-read-permission-notification")} />;
|
return <Notification type={"danger"} children={t("config.form.no-read-permission-notification")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.showNotification) {
|
if (showNotification) {
|
||||||
noPermissionNotification = (
|
noPermissionNotification = (
|
||||||
<Notification
|
<Notification
|
||||||
type={"info"}
|
type={"info"}
|
||||||
children={t("config.form.no-write-permission-notification")}
|
children={t("config.form.no-write-permission-notification")}
|
||||||
onClose={() => this.onClose()}
|
onClose={() => onClose()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={this.submit}>
|
<form onSubmit={submit}>
|
||||||
{noPermissionNotification}
|
{noPermissionNotification}
|
||||||
<GeneralSettings
|
<GeneralSettings
|
||||||
namespaceStrategies={namespaceStrategies}
|
namespaceStrategies={namespaceStrategies}
|
||||||
loginInfoUrl={config.loginInfoUrl}
|
loginInfoUrl={innerConfig.loginInfoUrl}
|
||||||
realmDescription={config.realmDescription}
|
realmDescription={innerConfig.realmDescription}
|
||||||
disableGroupingGrid={config.disableGroupingGrid}
|
disableGroupingGrid={innerConfig.disableGroupingGrid}
|
||||||
dateFormat={config.dateFormat}
|
dateFormat={innerConfig.dateFormat}
|
||||||
anonymousMode={config.anonymousMode}
|
anonymousMode={innerConfig.anonymousMode}
|
||||||
skipFailedAuthenticators={config.skipFailedAuthenticators}
|
skipFailedAuthenticators={innerConfig.skipFailedAuthenticators}
|
||||||
pluginUrl={config.pluginUrl}
|
pluginUrl={innerConfig.pluginUrl}
|
||||||
releaseFeedUrl={config.releaseFeedUrl}
|
releaseFeedUrl={innerConfig.releaseFeedUrl}
|
||||||
mailDomainName={config.mailDomainName}
|
mailDomainName={innerConfig.mailDomainName}
|
||||||
enabledXsrfProtection={config.enabledXsrfProtection}
|
enabledXsrfProtection={innerConfig.enabledXsrfProtection}
|
||||||
enabledUserConverter={config.enabledUserConverter}
|
enabledUserConverter={innerConfig.enabledUserConverter}
|
||||||
enabledApiKeys={config.enabledApiKeys}
|
enabledApiKeys={innerConfig.enabledApiKeys}
|
||||||
namespaceStrategy={config.namespaceStrategy}
|
emergencyContacts={innerConfig.emergencyContacts}
|
||||||
onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)}
|
namespaceStrategy={innerConfig.namespaceStrategy}
|
||||||
|
onChange={(isValid, changedValue, name) => onChange(isValid, changedValue, name)}
|
||||||
hasUpdatePermission={configUpdatePermission}
|
hasUpdatePermission={configUpdatePermission}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<LoginAttempt
|
<LoginAttempt
|
||||||
loginAttemptLimit={config.loginAttemptLimit}
|
loginAttemptLimit={innerConfig.loginAttemptLimit}
|
||||||
loginAttemptLimitTimeout={config.loginAttemptLimitTimeout}
|
loginAttemptLimitTimeout={innerConfig.loginAttemptLimitTimeout}
|
||||||
onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)}
|
onChange={(isValid, changedValue, name) => onChange(isValid, changedValue, name)}
|
||||||
hasUpdatePermission={configUpdatePermission}
|
hasUpdatePermission={configUpdatePermission}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<BaseUrlSettings
|
<BaseUrlSettings
|
||||||
baseUrl={config.baseUrl}
|
baseUrl={innerConfig.baseUrl}
|
||||||
forceBaseUrl={config.forceBaseUrl}
|
forceBaseUrl={innerConfig.forceBaseUrl}
|
||||||
onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)}
|
onChange={(isValid, changedValue, name) => onChange(isValid, changedValue, name)}
|
||||||
hasUpdatePermission={configUpdatePermission}
|
hasUpdatePermission={configUpdatePermission}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<ProxySettings
|
<ProxySettings
|
||||||
proxyPassword={config.proxyPassword ? config.proxyPassword : ""}
|
proxyPassword={innerConfig.proxyPassword ? innerConfig.proxyPassword : ""}
|
||||||
proxyPort={config.proxyPort}
|
proxyPort={innerConfig.proxyPort ? innerConfig.proxyPort : 0}
|
||||||
proxyServer={config.proxyServer ? config.proxyServer : ""}
|
proxyServer={innerConfig.proxyServer ? innerConfig.proxyServer : ""}
|
||||||
proxyUser={config.proxyUser ? config.proxyUser : ""}
|
proxyUser={innerConfig.proxyUser ? innerConfig.proxyUser : ""}
|
||||||
enableProxy={config.enableProxy}
|
enableProxy={innerConfig.enableProxy}
|
||||||
proxyExcludes={config.proxyExcludes}
|
proxyExcludes={innerConfig.proxyExcludes}
|
||||||
onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)}
|
onChange={(isValid, changedValue, name) => onChange(isValid, changedValue, name)}
|
||||||
hasUpdatePermission={configUpdatePermission}
|
hasUpdatePermission={configUpdatePermission}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -185,39 +184,12 @@ class ConfigForm extends React.Component<Props, State> {
|
|||||||
<SubmitButton
|
<SubmitButton
|
||||||
loading={loading}
|
loading={loading}
|
||||||
label={t("config.form.submit")}
|
label={t("config.form.submit")}
|
||||||
disabled={!configUpdatePermission || this.hasError() || !this.state.changed}
|
disabled={!configUpdatePermission || hasError() || !changed}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
onChange = (isValid: boolean, changedValue: any, name: string) => {
|
export default ConfigForm;
|
||||||
this.setState({
|
|
||||||
...this.state,
|
|
||||||
config: {
|
|
||||||
...this.state.config,
|
|
||||||
[name]: changedValue
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
...this.state.error,
|
|
||||||
[name]: !isValid
|
|
||||||
},
|
|
||||||
changed: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
hasError = () => {
|
|
||||||
return this.state.error.loginAttemptLimit || this.state.error.loginAttemptLimitTimeout;
|
|
||||||
};
|
|
||||||
|
|
||||||
onClose = () => {
|
|
||||||
this.setState({
|
|
||||||
...this.state,
|
|
||||||
showNotification: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withTranslation("config")(ConfigForm);
|
|
||||||
|
|||||||
@@ -21,13 +21,20 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC } from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Checkbox, InputField, Select } from "@scm-manager/ui-components";
|
import { useUserSuggestions } from "@scm-manager/ui-api";
|
||||||
import { NamespaceStrategies, AnonymousMode } from "@scm-manager/ui-types";
|
import { NamespaceStrategies, AnonymousMode, SelectValue } from "@scm-manager/ui-types";
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
InputField,
|
||||||
|
MemberNameTagGroup,
|
||||||
|
AutocompleteAddEntryToTableField,
|
||||||
|
Select
|
||||||
|
} from "@scm-manager/ui-components";
|
||||||
import NamespaceStrategySelect from "./NamespaceStrategySelect";
|
import NamespaceStrategySelect from "./NamespaceStrategySelect";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
realmDescription: string;
|
realmDescription: string;
|
||||||
loginInfoUrl: string;
|
loginInfoUrl: string;
|
||||||
disableGroupingGrid: boolean;
|
disableGroupingGrid: boolean;
|
||||||
@@ -40,29 +47,76 @@ type Props = WithTranslation & {
|
|||||||
enabledXsrfProtection: boolean;
|
enabledXsrfProtection: boolean;
|
||||||
enabledUserConverter: boolean;
|
enabledUserConverter: boolean;
|
||||||
enabledApiKeys: boolean;
|
enabledApiKeys: boolean;
|
||||||
|
emergencyContacts: string[];
|
||||||
namespaceStrategy: string;
|
namespaceStrategy: string;
|
||||||
namespaceStrategies?: NamespaceStrategies;
|
namespaceStrategies?: NamespaceStrategies;
|
||||||
onChange: (p1: boolean, p2: any, p3: string) => void;
|
onChange: (p1: boolean, p2: any, p3: string) => void;
|
||||||
hasUpdatePermission: boolean;
|
hasUpdatePermission: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
class GeneralSettings extends React.Component<Props> {
|
const GeneralSettings: FC<Props> = ({
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
t,
|
|
||||||
realmDescription,
|
realmDescription,
|
||||||
loginInfoUrl,
|
loginInfoUrl,
|
||||||
|
anonymousMode,
|
||||||
pluginUrl,
|
pluginUrl,
|
||||||
releaseFeedUrl,
|
releaseFeedUrl,
|
||||||
mailDomainName,
|
mailDomainName,
|
||||||
enabledXsrfProtection,
|
enabledXsrfProtection,
|
||||||
enabledUserConverter,
|
enabledUserConverter,
|
||||||
enabledApiKeys,
|
enabledApiKeys,
|
||||||
anonymousMode,
|
emergencyContacts,
|
||||||
namespaceStrategy,
|
namespaceStrategy,
|
||||||
hasUpdatePermission,
|
namespaceStrategies,
|
||||||
namespaceStrategies
|
onChange,
|
||||||
} = this.props;
|
hasUpdatePermission
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation("config");
|
||||||
|
const userSuggestions = useUserSuggestions();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -70,7 +124,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<InputField
|
<InputField
|
||||||
label={t("general-settings.realm-description")}
|
label={t("general-settings.realm-description")}
|
||||||
onChange={this.handleRealmDescriptionChange}
|
onChange={handleRealmDescriptionChange}
|
||||||
value={realmDescription}
|
value={realmDescription}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
helpText={t("help.realmDescriptionHelpText")}
|
helpText={t("help.realmDescriptionHelpText")}
|
||||||
@@ -79,7 +133,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<NamespaceStrategySelect
|
<NamespaceStrategySelect
|
||||||
label={t("general-settings.namespace-strategy")}
|
label={t("general-settings.namespace-strategy")}
|
||||||
onChange={this.handleNamespaceStrategyChange}
|
onChange={handleNamespaceStrategyChange}
|
||||||
value={namespaceStrategy}
|
value={namespaceStrategy}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
namespaceStrategies={namespaceStrategies}
|
namespaceStrategies={namespaceStrategies}
|
||||||
@@ -91,7 +145,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<InputField
|
<InputField
|
||||||
label={t("general-settings.login-info-url")}
|
label={t("general-settings.login-info-url")}
|
||||||
onChange={this.handleLoginInfoUrlChange}
|
onChange={handleLoginInfoUrlChange}
|
||||||
value={loginInfoUrl}
|
value={loginInfoUrl}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
helpText={t("help.loginInfoUrlHelpText")}
|
helpText={t("help.loginInfoUrlHelpText")}
|
||||||
@@ -100,7 +154,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("general-settings.enabled-xsrf-protection")}
|
label={t("general-settings.enabled-xsrf-protection")}
|
||||||
onChange={this.handleEnabledXsrfProtectionChange}
|
onChange={handleEnabledXsrfProtectionChange}
|
||||||
checked={enabledXsrfProtection}
|
checked={enabledXsrfProtection}
|
||||||
title={t("general-settings.enabled-xsrf-protection")}
|
title={t("general-settings.enabled-xsrf-protection")}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
@@ -112,7 +166,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<InputField
|
<InputField
|
||||||
label={t("general-settings.plugin-url")}
|
label={t("general-settings.plugin-url")}
|
||||||
onChange={this.handlePluginCenterUrlChange}
|
onChange={handlePluginCenterUrlChange}
|
||||||
value={pluginUrl}
|
value={pluginUrl}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
helpText={t("help.pluginUrlHelpText")}
|
helpText={t("help.pluginUrlHelpText")}
|
||||||
@@ -121,7 +175,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<Select
|
<Select
|
||||||
label={t("general-settings.anonymousMode.title")}
|
label={t("general-settings.anonymousMode.title")}
|
||||||
onChange={this.handleAnonymousMode}
|
onChange={handleAnonymousMode}
|
||||||
value={anonymousMode}
|
value={anonymousMode}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
options={[
|
options={[
|
||||||
@@ -138,7 +192,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<InputField
|
<InputField
|
||||||
label={t("general-settings.release-feed-url")}
|
label={t("general-settings.release-feed-url")}
|
||||||
onChange={this.handleReleaseFeedUrlChange}
|
onChange={handleReleaseFeedUrlChange}
|
||||||
value={releaseFeedUrl}
|
value={releaseFeedUrl}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
helpText={t("help.releaseFeedUrlHelpText")}
|
helpText={t("help.releaseFeedUrlHelpText")}
|
||||||
@@ -147,7 +201,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("general-settings.enabled-user-converter")}
|
label={t("general-settings.enabled-user-converter")}
|
||||||
onChange={this.handleEnabledUserConverterChange}
|
onChange={handleEnabledUserConverterChange}
|
||||||
checked={enabledUserConverter}
|
checked={enabledUserConverter}
|
||||||
title={t("general-settings.enabled-user-converter")}
|
title={t("general-settings.enabled-user-converter")}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
@@ -159,7 +213,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<InputField
|
<InputField
|
||||||
label={t("general-settings.mail-domain-name")}
|
label={t("general-settings.mail-domain-name")}
|
||||||
onChange={this.handleMailDomainNameChange}
|
onChange={handleMailDomainNameChange}
|
||||||
value={mailDomainName}
|
value={mailDomainName}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
helpText={t("help.mailDomainNameHelpText")}
|
helpText={t("help.mailDomainNameHelpText")}
|
||||||
@@ -168,7 +222,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("general-settings.enabled-api-keys")}
|
label={t("general-settings.enabled-api-keys")}
|
||||||
onChange={this.handleEnabledApiKeysChange}
|
onChange={handleEnabledApiKeysChange}
|
||||||
checked={enabledApiKeys}
|
checked={enabledApiKeys}
|
||||||
title={t("general-settings.enabled-api-keys")}
|
title={t("general-settings.enabled-api-keys")}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
@@ -176,40 +230,24 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-full">
|
||||||
|
<MemberNameTagGroup
|
||||||
|
members={emergencyContacts}
|
||||||
|
memberListChanged={handleEmergencyContactsChange}
|
||||||
|
label={t("general-settings.emergencyContacts.label")}
|
||||||
|
helpText={t("general-settings.emergencyContacts.helpText")}
|
||||||
|
/>
|
||||||
|
<AutocompleteAddEntryToTableField
|
||||||
|
addEntry={addEmergencyContact}
|
||||||
|
buttonLabel={t("general-settings.emergencyContacts.addButton")}
|
||||||
|
loadSuggestions={userSuggestions}
|
||||||
|
placeholder={t("general-settings.emergencyContacts.autocompletePlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleLoginInfoUrlChange = (value: string) => {
|
export default GeneralSettings;
|
||||||
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);
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { NamespaceStrategies } from "@scm-manager/ui-types";
|
|||||||
import { Select } from "@scm-manager/ui-components";
|
import { Select } from "@scm-manager/ui-components";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = WithTranslation & {
|
||||||
namespaceStrategies: NamespaceStrategies;
|
namespaceStrategies?: NamespaceStrategies;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -22,46 +22,25 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React, { FC } from "react";
|
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 { 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 CreateGroup: FC = () => {
|
||||||
const [t] = useTranslation("groups");
|
const [t] = useTranslation("groups");
|
||||||
const { isLoading, create, error, group } = useCreateGroup();
|
const { isLoading, create, error, group } = useCreateGroup();
|
||||||
const indexLinks = useIndexLinks();
|
const userSuggestions = useUserSuggestions();
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
return <Redirect to={`/group/${group.name}`} />;
|
return <Redirect to={`/group/${group.name}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<Page title={t("add-group.title")} subtitle={t("add-group.subtitle")} error={error || undefined}>
|
<Page title={t("add-group.title")} subtitle={t("add-group.subtitle")} error={error || undefined}>
|
||||||
<div>
|
<div>
|
||||||
<GroupForm submitForm={create} loading={isLoading} loadUserSuggestions={loadUserAutocompletion} />
|
<GroupForm submitForm={create} loading={isLoading} loadUserSuggestions={userSuggestions} />
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,49 +22,29 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React, { FC } from "react";
|
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 { 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 = {
|
type Props = {
|
||||||
group: Group;
|
group: Group;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditGroup: FC<Props> = ({ group }) => {
|
const EditGroup: FC<Props> = ({ group }) => {
|
||||||
const indexLinks = useIndexLinks();
|
|
||||||
const { error, isLoading, update, isUpdated } = useUpdateGroup();
|
const { error, isLoading, update, isUpdated } = useUpdateGroup();
|
||||||
const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users");
|
const userSuggestions = useUserSuggestions();
|
||||||
|
|
||||||
if (isUpdated) {
|
if (isUpdated) {
|
||||||
return <Redirect to={`/group/${group.name}`} />;
|
return <Redirect to={`/group/${group.name}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ErrorNotification error={error || undefined} />
|
<ErrorNotification error={error || undefined} />
|
||||||
<GroupForm group={group} submitForm={update} loading={isLoading} loadUserSuggestions={loadUserAutocompletion} />
|
<GroupForm group={group} submitForm={update} loading={isLoading} loadUserSuggestions={userSuggestions} />
|
||||||
<DeleteGroup group={group} />
|
<DeleteGroup group={group} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto {
|
|||||||
private String loginInfoUrl;
|
private String loginInfoUrl;
|
||||||
private String releaseFeedUrl;
|
private String releaseFeedUrl;
|
||||||
private String mailDomainName;
|
private String mailDomainName;
|
||||||
|
private Set<String> emergencyContacts;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
|
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
|
||||||
|
|||||||
@@ -74,4 +74,6 @@ interface UpdateConfigDto {
|
|||||||
String getReleaseFeedUrl();
|
String getReleaseFeedUrl();
|
||||||
|
|
||||||
String getMailDomainName();
|
String getMailDomainName();
|
||||||
|
|
||||||
|
Set<String> getEmergencyContacts();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,14 @@
|
|||||||
package sonia.scm.repository;
|
package sonia.scm.repository;
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
|
import org.apache.shiro.SecurityUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import sonia.scm.NotFoundException;
|
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.Command;
|
||||||
import sonia.scm.repository.api.RepositoryService;
|
import sonia.scm.repository.api.RepositoryService;
|
||||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||||
@@ -59,15 +64,22 @@ final class HealthChecker {
|
|||||||
|
|
||||||
private final ExecutorService healthCheckExecutor = Executors.newSingleThreadExecutor();
|
private final ExecutorService healthCheckExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
private final ScmConfiguration scmConfiguration;
|
||||||
|
private final NotificationSender notificationSender;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
HealthChecker(Set<HealthCheck> checks,
|
HealthChecker(Set<HealthCheck> checks,
|
||||||
RepositoryManager repositoryManager,
|
RepositoryManager repositoryManager,
|
||||||
RepositoryServiceFactory repositoryServiceFactory,
|
RepositoryServiceFactory repositoryServiceFactory,
|
||||||
RepositoryPostProcessor repositoryPostProcessor) {
|
RepositoryPostProcessor repositoryPostProcessor,
|
||||||
|
ScmConfiguration scmConfiguration,
|
||||||
|
NotificationSender notificationSender) {
|
||||||
this.checks = checks;
|
this.checks = checks;
|
||||||
this.repositoryManager = repositoryManager;
|
this.repositoryManager = repositoryManager;
|
||||||
this.repositoryServiceFactory = repositoryServiceFactory;
|
this.repositoryServiceFactory = repositoryServiceFactory;
|
||||||
this.repositoryPostProcessor = repositoryPostProcessor;
|
this.repositoryPostProcessor = repositoryPostProcessor;
|
||||||
|
this.scmConfiguration = scmConfiguration;
|
||||||
|
this.notificationSender = notificationSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
void lightCheck(String id) {
|
void lightCheck(String id) {
|
||||||
@@ -160,6 +172,7 @@ final class HealthChecker {
|
|||||||
HealthCheckResult fullCheckResult = gatherFullChecks(repository);
|
HealthCheckResult fullCheckResult = gatherFullChecks(repository);
|
||||||
HealthCheckResult result = lightCheckResult.merge(fullCheckResult);
|
HealthCheckResult result = lightCheckResult.merge(fullCheckResult);
|
||||||
|
|
||||||
|
notifyCurrentUser(repository, result);
|
||||||
storeResult(repository, result);
|
storeResult(repository, result);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -204,10 +217,32 @@ final class HealthChecker {
|
|||||||
logger.trace("store health check results for repository {}",
|
logger.trace("store health check results for repository {}",
|
||||||
repository);
|
repository);
|
||||||
repositoryPostProcessor.setCheckResults(repository, result.getFailures());
|
repositoryPostProcessor.setCheckResults(repository, result.getFailures());
|
||||||
|
|
||||||
|
notifyEmergencyContacts(repository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean checkRunning(String repositoryId) {
|
public boolean checkRunning(String repositoryId) {
|
||||||
return checksRunning.contains(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<String> 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,6 +427,7 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"exportFinished": "Der Repository Export wurde abgeschlossen.",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,7 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"exportFinished": "The repository export has been finished.",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
|||||||
private ConfigDtoToScmConfigurationMapperImpl mapper;
|
private ConfigDtoToScmConfigurationMapperImpl mapper;
|
||||||
|
|
||||||
private final String[] expectedExcludes = {"ex", "clude"};
|
private final String[] expectedExcludes = {"ex", "clude"};
|
||||||
|
private final String[] expectedUsers = {"trillian", "arthur"};
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void init() {
|
public void init() {
|
||||||
@@ -76,6 +77,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
|||||||
assertEquals("username", config.getNamespaceStrategy());
|
assertEquals("username", config.getNamespaceStrategy());
|
||||||
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
|
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
|
||||||
assertEquals("hitchhiker.mail", config.getMailDomainName());
|
assertEquals("hitchhiker.mail", config.getMailDomainName());
|
||||||
|
assertTrue("emergencyContacts", config.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -115,6 +117,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
|||||||
configDto.setNamespaceStrategy("username");
|
configDto.setNamespaceStrategy("username");
|
||||||
configDto.setLoginInfoUrl("https://scm-manager.org/login-info");
|
configDto.setLoginInfoUrl("https://scm-manager.org/login-info");
|
||||||
configDto.setMailDomainName("hitchhiker.mail");
|
configDto.setMailDomainName("hitchhiker.mail");
|
||||||
|
configDto.setEmergencyContacts(Sets.newSet(expectedUsers));
|
||||||
configDto.setEnabledUserConverter(false);
|
configDto.setEnabledUserConverter(false);
|
||||||
|
|
||||||
return configDto;
|
return configDto;
|
||||||
|
|||||||
@@ -49,11 +49,10 @@ import static org.mockito.MockitoAnnotations.initMocks;
|
|||||||
|
|
||||||
public class ScmConfigurationToConfigDtoMapperTest {
|
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 final String[] expectedExcludes = {"ex", "clude"};
|
||||||
private String[] expectedGroups = {"admin", "plebs"};
|
private final String[] expectedUsers = {"trillian", "arthur"};
|
||||||
private String[] expectedExcludes = {"ex", "clude"};
|
|
||||||
|
|
||||||
@SuppressWarnings("unused") // Is injected
|
@SuppressWarnings("unused") // Is injected
|
||||||
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
|
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
|
||||||
@@ -107,6 +106,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
|
|||||||
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
|
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
|
||||||
assertEquals("https://www.scm-manager.org/download/rss.xml", dto.getReleaseFeedUrl());
|
assertEquals("https://www.scm-manager.org/download/rss.xml", dto.getReleaseFeedUrl());
|
||||||
assertEquals("scm-manager.local", dto.getMailDomainName());
|
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("self").get().getHref());
|
||||||
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref());
|
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref());
|
||||||
@@ -161,6 +161,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
|
|||||||
config.setNamespaceStrategy("username");
|
config.setNamespaceStrategy("username");
|
||||||
config.setLoginInfoUrl("https://scm-manager.org/login-info");
|
config.setLoginInfoUrl("https://scm-manager.org/login-info");
|
||||||
config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml");
|
config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml");
|
||||||
|
config.setEmergencyContacts(Sets.newSet(expectedUsers));
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
package sonia.scm.repository;
|
package sonia.scm.repository;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
import org.apache.shiro.authz.AuthorizationException;
|
import org.apache.shiro.authz.AuthorizationException;
|
||||||
import org.apache.shiro.subject.Subject;
|
import org.apache.shiro.subject.Subject;
|
||||||
import org.apache.shiro.util.ThreadContext;
|
import org.apache.shiro.util.ThreadContext;
|
||||||
@@ -35,6 +36,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import sonia.scm.NotFoundException;
|
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.Command;
|
||||||
import sonia.scm.repository.api.FullHealthCheckCommandBuilder;
|
import sonia.scm.repository.api.FullHealthCheckCommandBuilder;
|
||||||
import sonia.scm.repository.api.RepositoryService;
|
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.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.awaitility.Awaitility.await;
|
import static org.awaitility.Awaitility.await;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
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.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.lenient;
|
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.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -74,6 +80,10 @@ class HealthCheckerTest {
|
|||||||
private RepositoryService repositoryService;
|
private RepositoryService repositoryService;
|
||||||
@Mock
|
@Mock
|
||||||
private RepositoryPostProcessor postProcessor;
|
private RepositoryPostProcessor postProcessor;
|
||||||
|
@Mock
|
||||||
|
private ScmConfiguration scmConfiguration;
|
||||||
|
@Mock
|
||||||
|
private NotificationSender notificationSender;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private Subject subject;
|
private Subject subject;
|
||||||
@@ -82,7 +92,7 @@ class HealthCheckerTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void initializeChecker() {
|
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
|
@BeforeEach
|
||||||
@@ -182,6 +192,7 @@ class HealthCheckerTest {
|
|||||||
void setUpRepository() {
|
void setUpRepository() {
|
||||||
lenient().when(repositoryServiceFactory.create(repository)).thenReturn(repositoryService);
|
lenient().when(repositoryServiceFactory.create(repository)).thenReturn(repositoryService);
|
||||||
lenient().when(repositoryService.getFullCheckCommand()).thenReturn(fullHealthCheckCommand);
|
lenient().when(repositoryService.getFullCheckCommand()).thenReturn(fullHealthCheckCommand);
|
||||||
|
lenient().when(subject.getPrincipal()).thenReturn("trillian");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -240,6 +251,32 @@ class HealthCheckerTest {
|
|||||||
return true;
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user