diff --git a/scm-core/src/main/java/sonia/scm/security/AccessToken.java b/scm-core/src/main/java/sonia/scm/security/AccessToken.java index 714b09eff8..c2a5f4b747 100644 --- a/scm-core/src/main/java/sonia/scm/security/AccessToken.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessToken.java @@ -31,6 +31,7 @@ package sonia.scm.security; import java.util.Date; +import java.util.Map; import java.util.Optional; /** @@ -38,70 +39,77 @@ import java.util.Optional; * be issued from a restful webservice endpoint by providing credentials. After the token was issued, the token must be * send along with every request. The token should be send in its compact representation as bearer authorization header * or as cookie. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ public interface AccessToken { - + /** * Returns unique id of the access token. - * + * * @return unique id */ String getId(); - + /** * Returns name of subject which identifies the principal. - * + * * @return name of subject */ String getSubject(); - + /** * Returns optional issuer. The issuer identifies the principal that issued the token. - * + * * @return optional issuer */ Optional getIssuer(); - + /** * Returns time at which the token was issued. - * + * * @return time at which the token was issued */ Date getIssuedAt(); - + /** * Returns the expiration time of token. - * + * * @return expiration time */ Date getExpiration(); - + + Optional getRefreshExpiration(); + /** - * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this + * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this * token. For example we could issue a token which can only be used to read a single repository. for more informations * please have a look at {@link Scope}. - * + * * @return scope of token. */ Scope getScope(); - + /** * Returns an optional value of a custom token field. - * + * * @param type of field * @param key key of token field - * + * * @return optional value of custom field */ Optional getCustom(String key); - + /** * Returns compact representation of token. - * + * * @return compact representation */ String compact(); + + /** + * Returns read only map of all claim keys with their values. + */ + Map getClaims(); } diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java index dd7986c22a..5e36ba468f 100644 --- a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java @@ -74,11 +74,21 @@ public interface AccessTokenBuilder { * Sets the expiration for the token. * * @param count expiration count - * @param unit expirtation unit + * @param unit expiration unit * * @return {@code this} */ AccessTokenBuilder expiresIn(long count, TimeUnit unit); + + /** + * Sets the time how long this token may be refreshed. Set this to 0 (zero) to disable automatic refresh. + * + * @param count Time unit count. If set to 0, automatic refresh is disabled. + * @param unit time unit + * + * @return {@code this} + */ + AccessTokenBuilder refreshableFor(long count, TimeUnit unit); /** * Reduces the permissions of the token by providing a scope. diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index 6377574498..b617351264 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -12,6 +12,6 @@ "@scm-manager/ui-extensions": "^0.1.1" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.22" } } diff --git a/scm-plugins/scm-git-plugin/yarn.lock b/scm-plugins/scm-git-plugin/yarn.lock index 3514ed3f2c..471beb513e 100644 --- a/scm-plugins/scm-git-plugin/yarn.lock +++ b/scm-plugins/scm-git-plugin/yarn.lock @@ -707,9 +707,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.22.tgz#6eaed4e1f0b1fbc6ed1ebbf7eb0f5585f760949a" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index dbca702070..6bccf3bd96 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -9,6 +9,6 @@ "@scm-manager/ui-extensions": "^0.1.1" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.22" } } diff --git a/scm-plugins/scm-hg-plugin/yarn.lock b/scm-plugins/scm-hg-plugin/yarn.lock index a211aa0ca1..bc53e0a7ce 100644 --- a/scm-plugins/scm-hg-plugin/yarn.lock +++ b/scm-plugins/scm-hg-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.22.tgz#6eaed4e1f0b1fbc6ed1ebbf7eb0f5585f760949a" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index 41f1c88a18..3e509172fd 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -9,6 +9,6 @@ "@scm-manager/ui-extensions": "^0.1.1" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.22" } } diff --git a/scm-plugins/scm-svn-plugin/yarn.lock b/scm-plugins/scm-svn-plugin/yarn.lock index a211aa0ca1..bc53e0a7ce 100644 --- a/scm-plugins/scm-svn-plugin/yarn.lock +++ b/scm-plugins/scm-svn-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.22.tgz#6eaed4e1f0b1fbc6ed1ebbf7eb0f5585f760949a" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-ui-components/packages/ui-components/package.json b/scm-ui-components/packages/ui-components/package.json index 4a4b4dc82e..880d8f3891 100644 --- a/scm-ui-components/packages/ui-components/package.json +++ b/scm-ui-components/packages/ui-components/package.json @@ -14,7 +14,7 @@ "eslint-fix": "eslint src --fix" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21", + "@scm-manager/ui-bundler": "^0.0.22", "create-index": "^2.3.0", "enzyme": "^3.5.0", "enzyme-adapter-react-16": "^1.3.1", @@ -34,7 +34,8 @@ "react-dom": "^16.5.2", "react-i18next": "^7.11.0", "react-jss": "^8.6.1", - "react-router-dom": "^4.3.1" + "react-router-dom": "^4.3.1", + "react-select": "^2.1.2" }, "browserify": { "transform": [ diff --git a/scm-ui-components/packages/ui-components/src/Autocomplete.js b/scm-ui-components/packages/ui-components/src/Autocomplete.js new file mode 100644 index 0000000000..f3023e268b --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/Autocomplete.js @@ -0,0 +1,73 @@ +// @flow +import React from "react"; +import { AsyncCreatable } from "react-select"; +import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types"; +import LabelWithHelpIcon from "./forms/LabelWithHelpIcon"; + + +type Props = { + loadSuggestions: string => Promise, + valueSelected: SelectValue => void, + label: string, + helpText?: string, + value?: SelectValue, + placeholder: string, + loadingMessage: string, + noOptionsMessage: string +}; + + +type State = {}; + +class Autocomplete extends React.Component { + + + static defaultProps = { + placeholder: "Type here", + loadingMessage: "Loading...", + noOptionsMessage: "No suggestion available" + }; + + handleInputChange = (newValue: SelectValue) => { + this.props.valueSelected(newValue); + }; + + // We overwrite this to avoid running into a bug (https://github.com/JedWatson/react-select/issues/2944) + isValidNewOption = (inputValue: string, selectValue: SelectValue, selectOptions: SelectValue[]) => { + const isNotDuplicated = !selectOptions + .map(option => option.label) + .includes(inputValue); + const isNotEmpty = inputValue !== ""; + return isNotEmpty && isNotDuplicated; + }; + + render() { + const { label, helpText, value, placeholder, loadingMessage, noOptionsMessage, loadSuggestions } = this.props; + return ( +
+ +
+ loadingMessage} + noOptionsMessage={() => noOptionsMessage} + isValidNewOption={this.isValidNewOption} + onCreateOption={value => { + this.handleInputChange({ + label: value, + value: { id: value, displayName: value } + }); + }} + /> +
+
+ ); + } +} + + +export default Autocomplete; diff --git a/scm-ui-components/packages/ui-components/src/ErrorNotification.js b/scm-ui-components/packages/ui-components/src/ErrorNotification.js index 9ef3b58653..5600d81799 100644 --- a/scm-ui-components/packages/ui-components/src/ErrorNotification.js +++ b/scm-ui-components/packages/ui-components/src/ErrorNotification.js @@ -18,7 +18,7 @@ class ErrorNotification extends React.Component { ); } - return ""; + return null; } } diff --git a/scm-ui-components/packages/ui-components/src/Paginator.test.js b/scm-ui-components/packages/ui-components/src/Paginator.test.js index 1d32e22faf..d32b4df702 100644 --- a/scm-ui-components/packages/ui-components/src/Paginator.test.js +++ b/scm-ui-components/packages/ui-components/src/Paginator.test.js @@ -1,12 +1,11 @@ // @flow import React from "react"; -import {mount, shallow} from "enzyme"; +import { mount, shallow } from "enzyme"; import "./tests/enzyme"; import "./tests/i18n"; import ReactRouterEnzymeContext from "react-router-enzyme-context"; import Paginator from "./Paginator"; -// TODO: Fix tests xdescribe("paginator rendering tests", () => { const options = new ReactRouterEnzymeContext(); diff --git a/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js b/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js new file mode 100644 index 0000000000..f3af79e658 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/forms/AutocompleteAddEntryToTableField.js @@ -0,0 +1,88 @@ +//@flow +import React from "react"; + +import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types"; +import Autocomplete from "../Autocomplete"; +import AddButton from "../buttons/AddButton"; + +type Props = { + addEntry: SelectValue => void, + disabled: boolean, + buttonLabel: string, + fieldLabel: string, + helpText?: string, + loadSuggestions: string => Promise, + placeholder?: string, + loadingMessage?: string, + noOptionsMessage?: string +}; + +type State = { + selectedValue?: SelectValue +}; + +class AutocompleteAddEntryToTableField extends React.Component { + constructor(props: Props) { + super(props); + this.state = { selectedValue: undefined }; + } + render() { + const { + disabled, + buttonLabel, + fieldLabel, + helpText, + loadSuggestions, + placeholder, + loadingMessage, + noOptionsMessage + } = this.props; + + const { selectedValue } = this.state; + return ( +
+ + + +
+ ); + } + + addButtonClicked = (event: Event) => { + event.preventDefault(); + this.appendEntry(); + }; + + appendEntry = () => { + const { selectedValue } = this.state; + if (!selectedValue) { + return; + } + // $FlowFixMe null is needed to clear the selection; undefined does not work + this.setState({ ...this.state, selectedValue: null }, () => + this.props.addEntry(selectedValue) + ); + }; + + handleAddEntryChange = (selection: SelectValue) => { + this.setState({ + ...this.state, + selectedValue: selection + }); + }; +} + +export default AutocompleteAddEntryToTableField; diff --git a/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js b/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js index 8a917828ec..c0e1dffb6a 100644 --- a/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js +++ b/scm-ui-components/packages/ui-components/src/forms/LabelWithHelpIcon.js @@ -1,6 +1,6 @@ //@flow import React from "react"; -import Help from '../Help'; +import Help from "../Help.js"; type Props = { label?: string, diff --git a/scm-ui-components/packages/ui-components/src/forms/index.js b/scm-ui-components/packages/ui-components/src/forms/index.js index b1cf06740f..714b9b3301 100644 --- a/scm-ui-components/packages/ui-components/src/forms/index.js +++ b/scm-ui-components/packages/ui-components/src/forms/index.js @@ -1,6 +1,7 @@ // @create-index export { default as AddEntryToTableField } from "./AddEntryToTableField.js"; +export { default as AutocompleteAddEntryToTableField } from "./AutocompleteAddEntryToTableField.js"; export { default as Checkbox } from "./Checkbox.js"; export { default as InputField } from "./InputField.js"; export { default as Select } from "./Select.js"; diff --git a/scm-ui-components/packages/ui-components/src/index.js b/scm-ui-components/packages/ui-components/src/index.js index 0900af2190..334e0a696c 100644 --- a/scm-ui-components/packages/ui-components/src/index.js +++ b/scm-ui-components/packages/ui-components/src/index.js @@ -23,6 +23,7 @@ export { default as Help } from "./Help"; export { default as HelpIcon } from "./HelpIcon"; export { default as Tooltip } from "./Tooltip"; export { getPageFromMatch } from "./urls"; +export { default as Autocomplete} from "./Autocomplete"; export { apiClient, NOT_FOUND_ERROR_MESSAGE, UNAUTHORIZED_ERROR_MESSAGE } from "./apiclient.js"; diff --git a/scm-ui-components/packages/ui-components/yarn.lock b/scm-ui-components/packages/ui-components/yarn.lock index 94816787ec..743e46f123 100644 --- a/scm-ui-components/packages/ui-components/yarn.lock +++ b/scm-ui-components/packages/ui-components/yarn.lock @@ -576,6 +576,12 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" +"@babel/runtime@^7.1.2": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39" + dependencies: + regenerator-runtime "^0.12.0" + "@babel/template@^7.1.0", "@babel/template@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" @@ -606,6 +612,46 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + "@gulp-sourcemaps/identity-map@1.X": version "1.0.2" resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9" @@ -641,9 +687,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.22.tgz#6eaed4e1f0b1fbc6ed1ebbf7eb0f5585f760949a" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -1121,6 +1167,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1134,6 +1197,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-macros@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28" + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -1633,12 +1707,24 @@ cached-path-relative@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + dependencies: + callsites "^2.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" dependencies: callsites "^0.2.0" +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -1782,7 +1868,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.6: +classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -1966,7 +2052,7 @@ contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" -convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" dependencies: @@ -1992,6 +2078,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04" + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + coveralls@^2.11.3: version "2.13.3" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" @@ -2009,6 +2104,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2122,6 +2229,10 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" @@ -2362,6 +2473,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-helpers@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" @@ -2466,6 +2583,13 @@ emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2561,7 +2685,7 @@ enzyme@^3.5.0: rst-selector-parser "^2.2.3" string.prototype.trim "^1.1.2" -error-ex@^1.2.0: +error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" dependencies: @@ -3070,6 +3194,10 @@ find-node-modules@^1.0.4: findup-sync "0.4.2" merge "^1.2.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3862,6 +3990,13 @@ immutable@^3: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" @@ -4038,6 +4173,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" @@ -4689,7 +4828,7 @@ js-yaml@3.6.1: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.12.0, js-yaml@^3.7.0: +js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -4743,6 +4882,10 @@ jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + json-schema-traverse@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" @@ -5127,7 +5270,7 @@ log-driver@1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" dependencies: @@ -5216,6 +5359,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" @@ -5556,6 +5703,12 @@ nopt@^4.0.1: abbrev "1" osenv "^0.1.4" +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + dependencies: + abbrev "1" + normalize-package-data@^2.3.2: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -5907,6 +6060,13 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + parse-passwd@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" @@ -6279,6 +6439,12 @@ react-i18next@^7.11.0: html-parse-stringify2 "2.0.1" prop-types "^15.6.0" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-is@^16.5.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" @@ -6293,6 +6459,10 @@ react-jss@^8.6.1: prop-types "^15.6.0" theming "^1.3.0" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-router-dom@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" @@ -6323,6 +6493,18 @@ react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-test-renderer@^16.0.0-0: version "16.5.2" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.5.2.tgz#92e9d2c6f763b9821b2e0b22f994ee675068b5ae" @@ -6332,6 +6514,15 @@ react-test-renderer@^16.0.0-0: react-is "^16.5.2" schedule "^0.5.0" +react-transition-group@^2.2.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874" + dependencies: + dom-helpers "^3.3.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.4.2: version "16.6.0" resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246" @@ -6466,6 +6657,10 @@ regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -6659,7 +6854,7 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: @@ -7033,6 +7228,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + sparkles@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c" @@ -7243,6 +7442,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -7442,6 +7649,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" diff --git a/scm-ui-components/packages/ui-types/package.json b/scm-ui-components/packages/ui-types/package.json index 78452c2ef5..8a009d314c 100644 --- a/scm-ui-components/packages/ui-types/package.json +++ b/scm-ui-components/packages/ui-types/package.json @@ -14,7 +14,7 @@ "check": "flow check" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21" + "@scm-manager/ui-bundler": "^0.0.22" }, "browserify": { "transform": [ @@ -33,4 +33,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/scm-ui-components/packages/ui-types/src/Autocomplete.js b/scm-ui-components/packages/ui-types/src/Autocomplete.js new file mode 100644 index 0000000000..407108c115 --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/Autocomplete.js @@ -0,0 +1,10 @@ +// @flow +export type AutocompleteObject = { + id: string, + displayName: string +}; + +export type SelectValue = { + value: AutocompleteObject, + label: string +}; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index 883272b4d4..cf739f747d 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -22,3 +22,5 @@ export type { IndexResources } from "./IndexResources"; export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export type { SubRepository, File } from "./Sources"; + +export type { SelectValue, AutocompleteObject } from "./Autocomplete"; diff --git a/scm-ui-components/packages/ui-types/yarn.lock b/scm-ui-components/packages/ui-types/yarn.lock index fe2df2f76a..a19d99dfbf 100644 --- a/scm-ui-components/packages/ui-types/yarn.lock +++ b/scm-ui-components/packages/ui-types/yarn.lock @@ -707,9 +707,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.22.tgz#6eaed4e1f0b1fbc6ed1ebbf7eb0f5585f760949a" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" diff --git a/scm-ui/package.json b/scm-ui/package.json index d80ee6571e..6fd0febd9c 100644 --- a/scm-ui/package.json +++ b/scm-ui/package.json @@ -28,6 +28,7 @@ "react-redux": "^5.0.7", "react-router-dom": "^4.3.1", "react-router-redux": "^5.0.0-alpha.9", + "react-select": "^2.1.2", "react-syntax-highlighter": "^9.0.1", "redux": "^4.0.0", "redux-devtools-extension": "^2.13.5", @@ -51,7 +52,7 @@ "pre-commit": "jest && flow && eslint src" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.21", + "@scm-manager/ui-bundler": "^0.0.22", "concat": "^1.0.3", "copyfiles": "^2.0.0", "enzyme": "^3.3.0", diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index daa2cc651a..f1ebb95e18 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -39,7 +39,13 @@ "label": "Add member", "error": "Invalid member name" }, - "group-form": { + "add-member-autocomplete": { + "placeholder": "Enter member", + "loading": "Loading...", + "no-options": "No suggestion available" + }, + +"group-form": { "submit": "Submit", "name-error": "Group name is invalid", "description-error": "Description is invalid", diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 7e421e7b99..8296b46907 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -84,11 +84,14 @@ "label": "Branches" }, "permission": { + "user": "User", + "group": "Group", "error-title": "Error", "error-subtitle": "Unknown permissions error", "name": "User or Group", "type": "Type", "group-permission": "Group Permission", + "user-permission": "User Permission", "edit-permission": { "delete-button": "Delete", "save-button": "Save Changes" @@ -111,6 +114,13 @@ "groupPermissionHelpText": "States if a permission is a group permission.", "nameHelpText": "Manage permissions for a specific user or group", "typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions" + }, + "autocomplete": { + "no-group-options": "No group suggestion available", + "group-placeholder": "Enter group", + "no-user-options": "No user suggestion available", + "user-placeholder": "Enter user", + "loading": "Loading..." } }, "help": { diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 4958fbf0fa..589914021c 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -2,12 +2,12 @@ import React from "react"; import { translate } from "react-i18next"; import { + AutocompleteAddEntryToTableField, InputField, SubmitButton, - Textarea, - AddEntryToTableField + Textarea } from "@scm-manager/ui-components"; -import type { Group } from "@scm-manager/ui-types"; +import type { Group, SelectValue } from "@scm-manager/ui-types"; import * as validator from "./groupValidation"; import MemberNameTable from "./MemberNameTable"; @@ -16,7 +16,8 @@ type Props = { t: string => string, submitForm: Group => void, loading?: boolean, - group?: Group + group?: Group, + loadUserSuggestions: string => any }; type State = { @@ -70,7 +71,7 @@ class GroupForm extends React.Component { render() { const { t, loading } = this.props; - const group = this.state.group; + const { group } = this.state; let nameField = null; if (!this.props.group) { nameField = ( @@ -97,15 +98,20 @@ class GroupForm extends React.Component { helpText={t("group-form.help.descriptionHelpText")} /> - { }); }; - addMember = (membername: string) => { - if (this.isMember(membername)) { + addMember = (value: SelectValue) => { + if (this.isMember(value.value.id)) { return; } @@ -135,7 +141,7 @@ class GroupForm extends React.Component { ...this.state, group: { ...this.state.group, - members: [...this.state.group.members, membername] + members: [...this.state.group.members, value.value.id] } }); }; diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 9b13ac0309..c19f6156d1 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -13,7 +13,10 @@ import { } from "../modules/groups"; import type { Group } from "@scm-manager/ui-types"; import type { History } from "history"; -import { getGroupsLink } from "../../modules/indexResource"; +import { + getGroupsLink, + getUserAutoCompleteLink +} from "../../modules/indexResource"; type Props = { t: string => string, @@ -22,7 +25,8 @@ type Props = { loading?: boolean, error?: Error, resetForm: () => void, - createLink: string + createLink: string, + autocompleteLink: string }; type State = {}; @@ -31,6 +35,7 @@ class AddGroup extends React.Component { componentDidMount() { this.props.resetForm(); } + render() { const { t, loading, error } = this.props; return ( @@ -43,12 +48,26 @@ class AddGroup extends React.Component { this.createGroup(group)} loading={loading} + loadUserSuggestions={this.loadUserAutocompletion} /> ); } + loadUserAutocompletion = (inputValue: string) => { + const url = this.props.autocompleteLink + "?q="; + return fetch(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; groupCreated = () => { this.props.history.push("/groups"); }; @@ -71,10 +90,12 @@ const mapStateToProps = state => { const loading = isCreateGroupPending(state); const error = getCreateGroupFailure(state); const createLink = getGroupsLink(state); + const autocompleteLink = getUserAutoCompleteLink(state); return { createLink, loading, - error + error, + autocompleteLink }; }; diff --git a/scm-ui/src/groups/containers/EditGroup.js b/scm-ui/src/groups/containers/EditGroup.js index ac6e737dac..223ea1eef6 100644 --- a/scm-ui/src/groups/containers/EditGroup.js +++ b/scm-ui/src/groups/containers/EditGroup.js @@ -3,21 +3,23 @@ import React from "react"; import { connect } from "react-redux"; import GroupForm from "../components/GroupForm"; import { - modifyGroup, - modifyGroupReset, + getModifyGroupFailure, isModifyGroupPending, - getModifyGroupFailure + modifyGroup, + modifyGroupReset } from "../modules/groups"; import type { History } from "history"; import { withRouter } from "react-router-dom"; import type { Group } from "@scm-manager/ui-types"; import { ErrorNotification } from "@scm-manager/ui-components"; +import { getUserAutoCompleteLink } from "../../modules/indexResource"; type Props = { group: Group, modifyGroup: (group: Group, callback?: () => void) => void, modifyGroupReset: Group => void, fetchGroup: (name: string) => void, + autocompleteLink: string, history: History, loading?: boolean, error: Error @@ -37,6 +39,20 @@ class EditGroup extends React.Component { this.props.modifyGroup(group, this.groupModified(group)); }; + loadUserAutocompletion = (inputValue: string) => { + const url = this.props.autocompleteLink + "?q="; + return fetch(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; + render() { const { group, loading, error } = this.props; return ( @@ -48,6 +64,7 @@ class EditGroup extends React.Component { this.modifyGroup(group); }} loading={loading} + loadUserSuggestions={this.loadUserAutocompletion} /> ); @@ -57,9 +74,11 @@ class EditGroup extends React.Component { const mapStateToProps = (state, ownProps) => { const loading = isModifyGroupPending(state, ownProps.group.name); const error = getModifyGroupFailure(state, ownProps.group.name); + const autocompleteLink = getUserAutoCompleteLink(state); return { loading, - error + error, + autocompleteLink }; }; diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js index 98dd9848dc..df55c63756 100644 --- a/scm-ui/src/modules/indexResource.js +++ b/scm-ui/src/modules/indexResource.js @@ -2,7 +2,7 @@ import * as types from "./types"; import { apiClient } from "@scm-manager/ui-components"; -import type { Action, IndexResources } from "@scm-manager/ui-types"; +import type { Action, IndexResources, Link } from "@scm-manager/ui-types"; import { isPending } from "./pending"; import { getFailure } from "./failure"; @@ -100,6 +100,13 @@ export function getLink(state: Object, name: string) { } } +export function getLinkCollection(state: Object, name: string): Link[] { + if (state.indexResources.links && state.indexResources.links[name]) { + return state.indexResources.links[name]; + } + return []; +} + export function getUiPluginsLink(state: Object) { return getLink(state, "uiPlugins"); } @@ -143,3 +150,23 @@ export function getGitConfigLink(state: Object) { export function getSvnConfigLink(state: Object) { return getLink(state, "svnConfig"); } + +export function getUserAutoCompleteLink(state: Object): string { + const link = getLinkCollection(state, "autocomplete").find( + i => i.name === "users" + ); + if (link) { + return link.href; + } + return ""; +} + +export function getGroupAutoCompleteLink(state: Object): string { + const link = getLinkCollection(state, "autocomplete").find( + i => i.name === "groups" + ); + if (link) { + return link.href; + } + return ""; +} diff --git a/scm-ui/src/modules/indexResource.test.js b/scm-ui/src/modules/indexResource.test.js index 2199da8290..ed3b7cb4d5 100644 --- a/scm-ui/src/modules/indexResource.test.js +++ b/scm-ui/src/modules/indexResource.test.js @@ -20,7 +20,11 @@ import reducer, { getHgConfigLink, getGitConfigLink, getSvnConfigLink, - getLinks, getGroupsLink + getLinks, + getGroupsLink, + getLinkCollection, + getUserAutoCompleteLink, + getGroupAutoCompleteLink } from "./indexResource"; const indexResourcesUnauthenticated = { @@ -73,354 +77,404 @@ const indexResourcesAuthenticated = { }, svnConfig: { href: "http://localhost:8081/scm/api/v2/config/svn" - } + }, + autocomplete: [ + { + href: "http://localhost:8081/scm/api/v2/autocomplete/users", + name: "users" + }, + { + href: "http://localhost:8081/scm/api/v2/autocomplete/groups", + name: "groups" + } + ] } }; -describe("fetch index resource", () => { - const index_url = "/api/v2/"; - const mockStore = configureMockStore([thunk]); +describe("index resource", () => { + describe("fetch index resource", () => { + const index_url = "/api/v2/"; + const mockStore = configureMockStore([thunk]); - afterEach(() => { - fetchMock.reset(); - fetchMock.restore(); - }); + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); - it("should successfully fetch index resources when unauthenticated", () => { - fetchMock.getOnce(index_url, indexResourcesUnauthenticated); + it("should successfully fetch index resources when unauthenticated", () => { + fetchMock.getOnce(index_url, indexResourcesUnauthenticated); - const expectedActions = [ - { type: FETCH_INDEXRESOURCES_PENDING }, - { - type: FETCH_INDEXRESOURCES_SUCCESS, - payload: indexResourcesUnauthenticated - } - ]; + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesUnauthenticated + } + ]; - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - expect(store.getActions()).toEqual(expectedActions); + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should successfully fetch index resources when authenticated", () => { + fetchMock.getOnce(index_url, indexResourcesAuthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesAuthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { + fetchMock.getOnce(index_url, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); + expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); }); }); - it("should successfully fetch index resources when authenticated", () => { - fetchMock.getOnce(index_url, indexResourcesAuthenticated); + describe("index resources reducer", () => { + it("should return empty object, if state and action is undefined", () => { + expect(reducer()).toEqual({}); + }); - const expectedActions = [ - { type: FETCH_INDEXRESOURCES_PENDING }, - { - type: FETCH_INDEXRESOURCES_SUCCESS, - payload: indexResourcesAuthenticated - } - ]; + it("should return the same state, if the action is undefined", () => { + const state = { x: true }; + expect(reducer(state)).toBe(state); + }); - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - expect(store.getActions()).toEqual(expectedActions); + it("should return the same state, if the action is unknown to the reducer", () => { + const state = { x: true }; + expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); + }); + + it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { + const newState = reducer( + {}, + fetchIndexResourcesSuccess(indexResourcesAuthenticated) + ); + expect(newState.links).toBe(indexResourcesAuthenticated._links); }); }); - it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { - fetchMock.getOnce(index_url, { - status: 500 + describe("index resources selectors", () => { + const error = new Error("something goes wrong"); + + it("should return true, when fetch index resources is pending", () => { + const state = { + pending: { + [FETCH_INDEXRESOURCES]: true + } + }; + expect(isFetchIndexResourcesPending(state)).toEqual(true); }); - const store = mockStore({}); - return store.dispatch(fetchIndexResources()).then(() => { - const actions = store.getActions(); - expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); - expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); - expect(actions[1].payload).toBeDefined(); + it("should return false, when fetch index resources is not pending", () => { + expect(isFetchIndexResourcesPending({})).toEqual(false); + }); + + it("should return error when fetch index resources did fail", () => { + const state = { + failure: { + [FETCH_INDEXRESOURCES]: error + } + }; + expect(getFetchIndexResourcesFailure(state)).toEqual(error); + }); + + it("should return undefined when fetch index resources did not fail", () => { + expect(getFetchIndexResourcesFailure({})).toBe(undefined); + }); + + it("should return all links", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); + }); + + // ui plugins link + it("should return ui plugins link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + it("should return ui plugins links when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + // me link + it("should return me link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); + }); + + it("should return undefined for me link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getMeLink(state)).toBe(undefined); + }); + + // logout link + it("should return logout link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLogoutLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLogoutLink(state)).toBe(undefined); + }); + + // login link + it("should return login link when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLoginLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for login link when authenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLoginLink(state)).toBe(undefined); + }); + + // users link + it("should return users link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUsersLink(state)).toBe( + "http://localhost:8081/scm/api/v2/users/" + ); + }); + + it("should return undefined for users link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUsersLink(state)).toBe(undefined); + }); + + // groups link + it("should return groups link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/groups/" + ); + }); + + it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGroupsLink(state)).toBe(undefined); + }); + + // config link + it("should return config link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config" + ); + }); + + it("should return undefined for config link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getConfigLink(state)).toBe(undefined); + }); + + // repositories link + it("should return repositories link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe( + "http://localhost:8081/scm/api/v2/repositories/" + ); + }); + + it("should return config for repositories link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe(undefined); + }); + + // hgConfig link + it("should return hgConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/hg" + ); + }); + + it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe(undefined); + }); + + // gitConfig link + it("should return gitConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/git" + ); + }); + + it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe(undefined); + }); + + // svnConfig link + it("should return svnConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/svn" + ); + }); + + it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe(undefined); + }); + + // Autocomplete links + it("should return link collection", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinkCollection(state, "autocomplete")).toEqual( + indexResourcesAuthenticated._links.autocomplete + ); + }); + + it("should return user autocomplete link", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUserAutoCompleteLink(state)).toEqual( + "http://localhost:8081/scm/api/v2/autocomplete/users" + ); + }); + + it("should return group autocomplete link", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupAutoCompleteLink(state)).toEqual( + "http://localhost:8081/scm/api/v2/autocomplete/groups" + ); }); }); }); - -describe("index resources reducer", () => { - it("should return empty object, if state and action is undefined", () => { - expect(reducer()).toEqual({}); - }); - - it("should return the same state, if the action is undefined", () => { - const state = { x: true }; - expect(reducer(state)).toBe(state); - }); - - it("should return the same state, if the action is unknown to the reducer", () => { - const state = { x: true }; - expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); - }); - - it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { - const newState = reducer( - {}, - fetchIndexResourcesSuccess(indexResourcesAuthenticated) - ); - expect(newState.links).toBe(indexResourcesAuthenticated._links); - }); -}); - -describe("index resources selectors", () => { - const error = new Error("something goes wrong"); - - it("should return true, when fetch index resources is pending", () => { - const state = { - pending: { - [FETCH_INDEXRESOURCES]: true - } - }; - expect(isFetchIndexResourcesPending(state)).toEqual(true); - }); - - it("should return false, when fetch index resources is not pending", () => { - expect(isFetchIndexResourcesPending({})).toEqual(false); - }); - - it("should return error when fetch index resources did fail", () => { - const state = { - failure: { - [FETCH_INDEXRESOURCES]: error - } - }; - expect(getFetchIndexResourcesFailure(state)).toEqual(error); - }); - - it("should return undefined when fetch index resources did not fail", () => { - expect(getFetchIndexResourcesFailure({})).toBe(undefined); - }); - - it("should return all links", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); - }); - - // ui plugins link - it("should return ui plugins link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getUiPluginsLink(state)).toBe( - "http://localhost:8081/scm/api/v2/ui/plugins" - ); - }); - - it("should return ui plugins links when unauthenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getUiPluginsLink(state)).toBe( - "http://localhost:8081/scm/api/v2/ui/plugins" - ); - }); - - // me link - it("should return me link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); - }); - - it("should return undefined for me link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getMeLink(state)).toBe(undefined); - }); - - // logout link - it("should return logout link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLogoutLink(state)).toBe( - "http://localhost:8081/scm/api/v2/auth/access_token" - ); - }); - - it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getLogoutLink(state)).toBe(undefined); - }); - - // login link - it("should return login link when unauthenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getLoginLink(state)).toBe( - "http://localhost:8081/scm/api/v2/auth/access_token" - ); - }); - - it("should return undefined for login link when authenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getLoginLink(state)).toBe(undefined); - }); - - // users link - it("should return users link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getUsersLink(state)).toBe("http://localhost:8081/scm/api/v2/users/"); - }); - - it("should return undefined for users link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getUsersLink(state)).toBe(undefined); - }); - - // groups link - it("should return groups link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getGroupsLink(state)).toBe("http://localhost:8081/scm/api/v2/groups/"); - }); - - it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getGroupsLink(state)).toBe(undefined); - }); - - // config link - it("should return config link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config" - ); - }); - - it("should return undefined for config link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getConfigLink(state)).toBe(undefined); - }); - - // repositories link - it("should return repositories link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getRepositoriesLink(state)).toBe( - "http://localhost:8081/scm/api/v2/repositories/" - ); - }); - - it("should return config for repositories link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getRepositoriesLink(state)).toBe(undefined); - }); - - // hgConfig link - it("should return hgConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getHgConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/hg" - ); - }); - - it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getHgConfigLink(state)).toBe(undefined); - }); - - // gitConfig link - it("should return gitConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getGitConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/git" - ); - }); - - it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getGitConfigLink(state)).toBe(undefined); - }); - - // svnConfig link - it("should return svnConfig link when authenticated and has permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesAuthenticated._links - } - }; - expect(getSvnConfigLink(state)).toBe( - "http://localhost:8081/scm/api/v2/config/svn" - ); - }); - - it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { - const state = { - indexResources: { - links: indexResourcesUnauthenticated._links - } - }; - expect(getSvnConfigLink(state)).toBe(undefined); - }); -}); diff --git a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js index a674f6b41f..13e6ee38ba 100644 --- a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js +++ b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js @@ -1,23 +1,30 @@ // @flow import React from "react"; -import {translate} from "react-i18next"; -import {Checkbox, InputField, SubmitButton} from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; +import { Autocomplete, SubmitButton } from "@scm-manager/ui-components"; import TypeSelector from "./TypeSelector"; -import type {PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; +import type { + PermissionCollection, + PermissionCreateEntry, + SelectValue +} from "@scm-manager/ui-types"; import * as validator from "./permissionValidation"; type Props = { t: string => string, createPermission: (permission: PermissionCreateEntry) => void, loading: boolean, - currentPermissions: PermissionCollection + currentPermissions: PermissionCollection, + groupAutoCompleteLink: string, + userAutoCompleteLink: string }; type State = { name: string, type: string, groupPermission: boolean, - valid: boolean + valid: boolean, + value?: SelectValue }; class CreatePermissionForm extends React.Component { @@ -28,13 +35,95 @@ class CreatePermissionForm extends React.Component { name: "", type: "READ", groupPermission: false, - valid: true + valid: true, + value: undefined }; } + permissionScopeChanged = event => { + const groupPermission = event.target.value === "GROUP_PERMISSION"; + this.setState({ + groupPermission: groupPermission, + valid: validator.isPermissionValid( + this.state.name, + groupPermission, + this.props.currentPermissions + ) + }); + this.setState({ ...this.state, groupPermission }); + }; + + loadUserAutocompletion = (inputValue: string) => { + return this.loadAutocompletion(this.props.userAutoCompleteLink, inputValue); + }; + + loadGroupAutocompletion = (inputValue: string) => { + return this.loadAutocompletion( + this.props.groupAutoCompleteLink, + inputValue + ); + }; + + loadAutocompletion(url: string, inputValue: string) { + const link = url + "?q="; + return fetch(link + inputValue) + .then(response => response.json()) + .then(json => { + return json.map(element => { + const label = element.displayName + ? `${element.displayName} (${element.id})` + : element.id; + return { + value: element, + label + }; + }); + }); + } + renderAutocompletionField = () => { + const { t } = this.props; + if (this.state.groupPermission) { + return ( + + ); + } + return ( + + ); + }; + + groupOrUserSelected = (value: SelectValue) => { + this.setState({ + value, + name: value.value.id, + valid: validator.isPermissionValid( + value.value.id, + this.state.groupPermission, + this.props.currentPermissions + ) + }); + }; + render() { const { t, loading } = this.props; - const { name, type, groupPermission } = this.state; + + const { type } = this.state; return (
@@ -42,20 +131,30 @@ class CreatePermissionForm extends React.Component { {t("permission.add-permission.add-permission-heading")}
- - +
+ + +
+ {this.renderAutocompletionField()} + { type: type }); }; - - handleNameChange = (name: string) => { - this.setState({ - name: name, - valid: validator.isPermissionValid( - name, - this.state.groupPermission, - this.props.currentPermissions - ) - }); - }; - handleGroupPermissionChange = (groupPermission: boolean) => { - this.setState({ - groupPermission: groupPermission, - valid: validator.isPermissionValid( - this.state.name, - groupPermission, - this.props.currentPermissions - ) - }); - }; } export default translate("repos")(CreatePermissionForm); diff --git a/scm-ui/src/repos/permissions/containers/Permissions.js b/scm-ui/src/repos/permissions/containers/Permissions.js index ee9ac281a5..1a8d6b9f3a 100644 --- a/scm-ui/src/repos/permissions/containers/Permissions.js +++ b/scm-ui/src/repos/permissions/containers/Permissions.js @@ -27,6 +27,10 @@ import SinglePermission from "./SinglePermission"; import CreatePermissionForm from "../components/CreatePermissionForm"; import type { History } from "history"; import { getPermissionsLink } from "../../modules/repos"; +import { + getGroupAutoCompleteLink, + getUserAutoCompleteLink +} from "../../../modules/indexResource"; type Props = { namespace: string, @@ -37,6 +41,8 @@ type Props = { hasPermissionToCreate: boolean, loadingCreatePermission: boolean, permissionsLink: string, + groupAutoCompleteLink: string, + userAutoCompleteLink: string, //dispatch functions fetchPermissions: (link: string, namespace: string, repoName: string) => void, @@ -92,7 +98,9 @@ class Permissions extends React.Component { namespace, repoName, loadingCreatePermission, - hasPermissionToCreate + hasPermissionToCreate, + userAutoCompleteLink, + groupAutoCompleteLink } = this.props; if (error) { return ( @@ -113,6 +121,8 @@ class Permissions extends React.Component { createPermission={permission => this.createPermission(permission)} loading={loadingCreatePermission} currentPermissions={permissions} + userAutoCompleteLink={userAutoCompleteLink} + groupAutoCompleteLink={groupAutoCompleteLink} /> ) : null; @@ -165,6 +175,8 @@ const mapStateToProps = (state, ownProps) => { ); const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName); const permissionsLink = getPermissionsLink(state, namespace, repoName); + const groupAutoCompleteLink = getGroupAutoCompleteLink(state); + const userAutoCompleteLink = getUserAutoCompleteLink(state); return { namespace, repoName, @@ -173,7 +185,9 @@ const mapStateToProps = (state, ownProps) => { permissions, hasPermissionToCreate, loadingCreatePermission, - permissionsLink + permissionsLink, + groupAutoCompleteLink, + userAutoCompleteLink }; }; @@ -189,7 +203,9 @@ const mapDispatchToProps = dispatch => { repoName: string, callback?: () => void ) => { - dispatch(createPermission(link, permission, namespace, repoName, callback)); + dispatch( + createPermission(link, permission, namespace, repoName, callback) + ); }, createPermissionReset: (namespace: string, repoName: string) => { dispatch(createPermissionReset(namespace, repoName)); diff --git a/scm-ui/yarn.lock b/scm-ui/yarn.lock index 2167ed28c9..6aeb0f7703 100644 --- a/scm-ui/yarn.lock +++ b/scm-ui/yarn.lock @@ -583,6 +583,12 @@ "@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0" +"@babel/runtime@^7.1.2": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39" + dependencies: + regenerator-runtime "^0.12.0" + "@babel/template@^7.1.0", "@babel/template@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" @@ -613,6 +619,46 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + "@fortawesome/fontawesome-free@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.3.1.tgz#5466b8f31c1f493a96754c1426c25796d0633dd9" @@ -652,9 +698,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.21": - version "0.0.21" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" +"@scm-manager/ui-bundler@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.22.tgz#6eaed4e1f0b1fbc6ed1ebbf7eb0f5585f760949a" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -1144,6 +1190,23 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1157,6 +1220,17 @@ babel-plugin-jest-hoist@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" +babel-plugin-macros@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28" + dependencies: + cosmiconfig "^5.0.5" + resolve "^1.8.1" + +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "http://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -1667,12 +1741,24 @@ cached-path-relative@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + dependencies: + callsites "^2.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" dependencies: callsites "^0.2.0" +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -2049,7 +2135,7 @@ contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" -convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" dependencies: @@ -2086,6 +2172,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cosmiconfig@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04" + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + coveralls@^2.11.3: version "2.13.3" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" @@ -2103,6 +2198,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2213,6 +2320,10 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -2476,6 +2587,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-helpers@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" @@ -2584,6 +2701,13 @@ emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -3205,6 +3329,10 @@ find-node-modules@^1.0.4: findup-sync "0.4.2" merge "^1.2.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -4096,6 +4224,13 @@ immutable@^3: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" @@ -4297,6 +4432,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + is-dotfile@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" @@ -4956,7 +5095,7 @@ js-yaml@3.6.1: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.12.0, js-yaml@^3.7.0: +js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.12.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" dependencies: @@ -5431,7 +5570,7 @@ log-driver@1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" dependencies: @@ -5542,6 +5681,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" +memoize-one@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" @@ -5959,7 +6102,7 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" -nopt@1.0.10: +nopt@1.0.10, nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" dependencies: @@ -6805,6 +6948,12 @@ react-i18next@^7.9.0: html-parse-stringify2 "2.0.1" prop-types "^15.6.0" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-is@^16.5.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" @@ -6819,6 +6968,10 @@ react-jss@^8.6.0: prop-types "^15.6.0" theming "^1.3.0" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-redux@^5.0.7: version "5.0.7" resolved "http://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8" @@ -6868,6 +7021,18 @@ react-router@^4.2.0, react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-syntax-highlighter@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-9.0.1.tgz#cad91692e1976f68290f24762ac3451b1fec3d26" @@ -6887,6 +7052,15 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.4.1: react-is "^16.5.2" schedule "^0.5.0" +react-transition-group@^2.2.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874" + dependencies: + dom-helpers "^3.3.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.4.2: version "16.5.2" resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" @@ -7068,6 +7242,10 @@ regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -7286,7 +7464,7 @@ resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" -resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: @@ -7683,6 +7861,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + space-separated-tokens@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.2.tgz#e95ab9d19ae841e200808cd96bc7bd0adbbb3412" @@ -7933,6 +8115,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -8135,6 +8325,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml index 4dfb749690..01f45cb54e 100644 --- a/scm-webapp/pom.xml +++ b/scm-webapp/pom.xml @@ -72,8 +72,18 @@ io.jsonwebtoken - jjwt - 0.4 + jjwt-impl + ${jjwt.version} + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} @@ -540,6 +550,7 @@ DEVELOPMENT target/scm-it default + 0.10.5 2.53.1 1.0 0.8.17 diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java index 90764a7e00..9555ad66b5 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java @@ -83,8 +83,10 @@ import sonia.scm.security.AuthorizationChangedEventProducer; import sonia.scm.security.CipherHandler; import sonia.scm.security.CipherUtil; import sonia.scm.security.ConfigurableLoginAttemptHandler; +import sonia.scm.security.DefaultJwtAccessTokenRefreshStrategy; import sonia.scm.security.DefaultKeyGenerator; import sonia.scm.security.DefaultSecuritySystem; +import sonia.scm.security.JwtAccessTokenRefreshStrategy; import sonia.scm.security.KeyGenerator; import sonia.scm.security.LoginAttemptHandler; import sonia.scm.security.SecuritySystem; diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultJwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultJwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..266a327d44 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultJwtAccessTokenRefreshStrategy.java @@ -0,0 +1,10 @@ +package sonia.scm.security; + +import sonia.scm.plugin.Extension; + +@Extension +public class DefaultJwtAccessTokenRefreshStrategy extends PercentageJwtAccessTokenRefreshStrategy { + public DefaultJwtAccessTokenRefreshStrategy() { + super(0.5F); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java index 46f4c68e74..8fb5929188 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java @@ -31,9 +31,14 @@ package sonia.scm.security; import io.jsonwebtoken.Claims; + +import java.util.Collections; import java.util.Date; +import java.util.Map; import java.util.Optional; +import static java.util.Optional.ofNullable; + /** * Jwt implementation of {@link AccessToken}. * @@ -41,7 +46,9 @@ import java.util.Optional; * @since 2.0.0 */ public final class JwtAccessToken implements AccessToken { - + + public static final String REFRESHABLE_UNTIL_CLAIM_KEY = "scm-manager.refreshExpiration"; + public static final String PARENT_TOKEN_ID_CLAIM_KEY = "scm-manager.parentTokenId"; private final Claims claims; private final String compact; @@ -75,6 +82,15 @@ public final class JwtAccessToken implements AccessToken { return claims.getExpiration(); } + @Override + public Optional getRefreshExpiration() { + return ofNullable(claims.get(REFRESHABLE_UNTIL_CLAIM_KEY, Date.class)); + } + + public Optional getParentKey() { + return ofNullable(claims.get(PARENT_TOKEN_ID_CLAIM_KEY).toString()); + } + @Override public Scope getScope() { return Scopes.fromClaims(claims); @@ -90,5 +106,9 @@ public final class JwtAccessToken implements AccessToken { public String compact() { return compact; } - + + @Override + public Map getClaims() { + return Collections.unmodifiableMap(claims); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java index ece96e2954..66db720125 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java @@ -36,10 +36,12 @@ import com.google.common.collect.Maps; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; + +import java.time.Clock; +import java.time.Instant; import java.util.Date; import java.util.HashMap; import java.util.Map; -import java.util.Set; import java.util.concurrent.TimeUnit; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; @@ -48,7 +50,7 @@ import org.slf4j.LoggerFactory; /** * Jwt implementation of {@link AccessTokenBuilder}. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ @@ -58,21 +60,27 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { * the logger for JwtAccessTokenBuilder */ private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenBuilder.class); - - private final KeyGenerator keyGenerator; - private final SecureKeyResolver keyResolver; - + + private final KeyGenerator keyGenerator; + private final SecureKeyResolver keyResolver; + private final Clock clock; + private String subject; private String issuer; - private long expiresIn = 60l; - private TimeUnit expiresInUnit = TimeUnit.MINUTES; + private long expiresIn = 1; + private TimeUnit expiresInUnit = TimeUnit.HOURS; + private long refreshableFor = 12; + private TimeUnit refreshableForUnit = TimeUnit.HOURS; + private Instant refreshExpiration; + private String parentKeyId; private Scope scope = Scope.empty(); - + private final Map custom = Maps.newHashMap(); - - JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver) { + + JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Clock clock) { this.keyGenerator = keyGenerator; this.keyResolver = keyResolver; + this.clock = clock; } @Override @@ -81,7 +89,7 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { this.subject = subject; return this; } - + @Override public JwtAccessTokenBuilder custom(String key, Object value) { Preconditions.checkArgument(!Strings.isNullOrEmpty(key), "null or empty value not allowed"); @@ -92,11 +100,11 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { @Override public JwtAccessTokenBuilder scope(Scope scope) { - Preconditions.checkArgument(scope != null, "scope can not be null"); + Preconditions.checkArgument(scope != null, "scope cannot be null"); this.scope = scope; return this; } - + @Override public JwtAccessTokenBuilder issuer(String issuer) { Preconditions.checkArgument(!Strings.isNullOrEmpty(issuer), "null or empty value not allowed"); @@ -106,15 +114,37 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { @Override public JwtAccessTokenBuilder expiresIn(long count, TimeUnit unit) { - Preconditions.checkArgument(count > 0, "expires in must be greater than 0"); - Preconditions.checkArgument(unit != null, "unit can not be null"); - + Preconditions.checkArgument(count > 0, "count must be greater than 0"); + Preconditions.checkArgument(unit != null, "unit cannot be null"); + this.expiresIn = count; this.expiresInUnit = unit; - + return this; } - + + @Override + public JwtAccessTokenBuilder refreshableFor(long count, TimeUnit unit) { + Preconditions.checkArgument(count >= 0, "count must be greater or equal to 0"); + Preconditions.checkArgument(unit != null, "unit cannot be null"); + + this.refreshableFor = count; + this.refreshableForUnit = unit; + + return this; + } + + JwtAccessTokenBuilder refreshExpiration(Instant refreshExpiration) { + this.refreshExpiration = refreshExpiration; + this.refreshableFor = 0; + return this; + } + + public JwtAccessTokenBuilder parentKey(String parentKeyId) { + this.parentKeyId = parentKeyId; + return this; + } + private String getSubject(){ if (subject == null) { Subject currentSubject = SecurityUtils.getSubject(); @@ -130,35 +160,48 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { String id = keyGenerator.createKey(); String sub = getSubject(); - + LOG.trace("create new token {} for user {}", id, subject); SecureKey key = keyResolver.getSecureKey(sub); - + Map customClaims = new HashMap<>(custom); - + // add scope to custom claims Scopes.toClaims(customClaims, scope); - - Date now = new Date(); + + Instant now = clock.instant(); long expiration = expiresInUnit.toMillis(expiresIn); - + Claims claims = Jwts.claims(customClaims) .setSubject(sub) .setId(id) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + expiration)); - + .setIssuedAt(Date.from(now)) + .setExpiration(new Date(now.toEpochMilli() + expiration)); + + + if (refreshableFor > 0) { + long refreshExpiration = refreshableForUnit.toMillis(refreshableFor); + claims.put(JwtAccessToken.REFRESHABLE_UNTIL_CLAIM_KEY, new Date(now.toEpochMilli() + refreshExpiration).getTime()); + } else if (refreshExpiration != null) { + claims.put(JwtAccessToken.REFRESHABLE_UNTIL_CLAIM_KEY, Date.from(refreshExpiration)); + } + if (parentKeyId == null) { + claims.put(JwtAccessToken.PARENT_TOKEN_ID_CLAIM_KEY, id); + } else { + claims.put(JwtAccessToken.PARENT_TOKEN_ID_CLAIM_KEY, parentKeyId); + } + if ( issuer != null ) { claims.setIssuer(issuer); } - + // sign token and create compact version String compact = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS256, key.getBytes()) .compact(); - + return new JwtAccessToken(claims, compact); } - + } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java index 63c4ea981c..a5704b0e82 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilderFactory.java @@ -30,6 +30,7 @@ */ package sonia.scm.security; +import java.time.Clock; import java.util.Set; import javax.inject.Inject; import sonia.scm.plugin.Extension; @@ -46,19 +47,25 @@ public final class JwtAccessTokenBuilderFactory implements AccessTokenBuilderFac private final KeyGenerator keyGenerator; private final SecureKeyResolver keyResolver; private final Set enrichers; + private final Clock clock; @Inject public JwtAccessTokenBuilderFactory( - KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set enrichers - ) { + KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set enrichers) { + this(keyGenerator, keyResolver, enrichers, Clock.systemDefaultZone()); + } + + JwtAccessTokenBuilderFactory( + KeyGenerator keyGenerator, SecureKeyResolver keyResolver, Set enrichers, Clock clock) { this.keyGenerator = keyGenerator; this.keyResolver = keyResolver; this.enrichers = enrichers; + this.clock = clock; } @Override public JwtAccessTokenBuilder create() { - JwtAccessTokenBuilder builder = new JwtAccessTokenBuilder(keyGenerator, keyResolver); + JwtAccessTokenBuilder builder = new JwtAccessTokenBuilder(keyGenerator, keyResolver, clock); // enrich access token builder enrichers.forEach((enricher) -> { diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..9135a0e099 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java @@ -0,0 +1,8 @@ +package sonia.scm.security; + +import sonia.scm.plugin.ExtensionPoint; + +@ExtensionPoint(multi = false) +public interface JwtAccessTokenRefreshStrategy { + boolean shouldBeRefreshed(JwtAccessToken oldToken); +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java new file mode 100644 index 0000000000..6db01c904f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java @@ -0,0 +1,77 @@ +package sonia.scm.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.time.Clock; +import java.util.Date; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +public class JwtAccessTokenRefresher { + + private static final Logger log = LoggerFactory.getLogger(JwtAccessTokenRefresher.class); + + private final JwtAccessTokenBuilderFactory builderFactory; + private final JwtAccessTokenRefreshStrategy refreshStrategy; + private final Clock clock; + + @Inject + public JwtAccessTokenRefresher(JwtAccessTokenBuilderFactory builderFactory, JwtAccessTokenRefreshStrategy refreshStrategy) { + this(builderFactory, refreshStrategy, Clock.systemDefaultZone()); + } + + JwtAccessTokenRefresher(JwtAccessTokenBuilderFactory builderFactory, JwtAccessTokenRefreshStrategy refreshStrategy, Clock clock) { + this.builderFactory = builderFactory; + this.refreshStrategy = refreshStrategy; + this.clock = clock; + } + + @SuppressWarnings("squid:S3655") // the refresh expiration cannot be null at the time building the new token, because + // we checked this before in tokenCanBeRefreshed + public Optional refresh(JwtAccessToken oldToken) { + JwtAccessTokenBuilder builder = builderFactory.create(); + Map claims = oldToken.getClaims(); + claims.forEach(builder::custom); + + if (canBeRefreshed(oldToken) && shouldBeRefreshed(oldToken)) { + Optional parentTokenId = oldToken.getCustom("scm-manager.parentTokenId"); + if (!parentTokenId.isPresent()) { + log.warn("no parent token id found in token; could not refresh"); + return Optional.empty(); + } + builder.expiresIn(computeOldExpirationInMillis(oldToken), TimeUnit.MILLISECONDS); + builder.parentKey(parentTokenId.get().toString()); + builder.refreshExpiration(oldToken.getRefreshExpiration().get().toInstant()); + return Optional.of(builder.build()); + } else { + return Optional.empty(); + } + } + + private long computeOldExpirationInMillis(JwtAccessToken oldToken) { + return oldToken.getExpiration().getTime() - oldToken.getIssuedAt().getTime(); + } + + private boolean canBeRefreshed(JwtAccessToken oldToken) { + return tokenIsValid(oldToken) && tokenCanBeRefreshed(oldToken); + } + + private boolean shouldBeRefreshed(JwtAccessToken oldToken) { + return refreshStrategy.shouldBeRefreshed(oldToken); + } + + private boolean tokenCanBeRefreshed(JwtAccessToken oldToken) { + return oldToken.getRefreshExpiration().map(this::isAfterNow).orElse(false); + } + + private boolean tokenIsValid(JwtAccessToken oldToken) { + return isAfterNow(oldToken.getExpiration()); + } + + private boolean isAfterNow(Date expiration) { + return expiration.toInstant().isAfter(clock.instant()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..c78654c389 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategy.java @@ -0,0 +1,25 @@ +package sonia.scm.security; + +import java.time.Clock; + +public class PercentageJwtAccessTokenRefreshStrategy implements JwtAccessTokenRefreshStrategy { + + private final Clock clock; + private final float refreshPercentage; + + public PercentageJwtAccessTokenRefreshStrategy(float refreshPercentage) { + this(Clock.systemDefaultZone(), refreshPercentage); + } + + PercentageJwtAccessTokenRefreshStrategy(Clock clock, float refreshPercentage) { + this.clock = clock; + this.refreshPercentage = refreshPercentage; + } + + @Override + public boolean shouldBeRefreshed(JwtAccessToken oldToken) { + long liveSpan = oldToken.getExpiration().getTime() - oldToken.getIssuedAt().getTime(); + long age = clock.instant().toEpochMilli() - oldToken.getIssuedAt().getTime(); + return (float)age/liveSpan > refreshPercentage; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java new file mode 100644 index 0000000000..f85c0fbbbd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/web/security/TokenRefreshFilter.java @@ -0,0 +1,78 @@ +package sonia.scm.web.security; + +import org.apache.shiro.authc.AuthenticationToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.Priority; +import sonia.scm.filter.Filters; +import sonia.scm.filter.WebElement; +import sonia.scm.security.AccessToken; +import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.AccessTokenResolver; +import sonia.scm.security.BearerToken; +import sonia.scm.security.JwtAccessToken; +import sonia.scm.security.JwtAccessTokenRefresher; +import sonia.scm.web.WebTokenGenerator; +import sonia.scm.web.filter.HttpFilter; + +import javax.inject.Inject; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import java.util.Set; + +import static java.util.Optional.empty; +import static java.util.Optional.of; + +@Priority(Filters.PRIORITY_POST_AUTHENTICATION) +@WebElement(value = Filters.PATTERN_RESTAPI, + morePatterns = { Filters.PATTERN_DEBUG }) +public class TokenRefreshFilter extends HttpFilter { + + private static final Logger LOG = LoggerFactory.getLogger(TokenRefreshFilter.class); + + private final Set tokenGenerators; + private final JwtAccessTokenRefresher refresher; + private final AccessTokenResolver resolver; + private final AccessTokenCookieIssuer issuer; + + @Inject + public TokenRefreshFilter(Set tokenGenerators, JwtAccessTokenRefresher refresher, AccessTokenResolver resolver, AccessTokenCookieIssuer issuer) { + this.tokenGenerators = tokenGenerators; + this.refresher = refresher; + this.resolver = resolver; + this.issuer = issuer; + } + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + extractToken(request).ifPresent(token -> examineToken(request, response, token)); + chain.doFilter(request, response); + } + + private Optional extractToken(HttpServletRequest request) { + for (WebTokenGenerator generator : tokenGenerators) { + AuthenticationToken token = generator.createToken(request); + if (token instanceof BearerToken) { + return of((BearerToken) token); + } + } + return empty(); + } + + private void examineToken(HttpServletRequest request, HttpServletResponse response, BearerToken token) { + AccessToken accessToken = resolver.resolve(token); + if (accessToken instanceof JwtAccessToken) { + refresher.refresh((JwtAccessToken) accessToken) + .ifPresent(jwtAccessToken -> refreshToken(request, response, jwtAccessToken)); + } + } + + private void refreshToken(HttpServletRequest request, HttpServletResponse response, JwtAccessToken jwtAccessToken) { + LOG.debug("refreshing authentication token"); + issuer.authenticate(request, response, jwtAccessToken); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index e6061e61a1..26dfcb2099 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -61,7 +61,6 @@ import sonia.scm.user.UserDAO; import sonia.scm.user.UserTestData; import javax.crypto.spec.SecretKeySpec; -import java.security.SecureRandom; import java.util.Date; import java.util.Set; @@ -71,6 +70,7 @@ import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; /** * Unit tests for {@link BearerRealm}. @@ -256,12 +256,6 @@ private String createCompactToken(String subject, SecureKey key) { .compact(); } - private SecureKey createSecureKey() { - byte[] bytes = new byte[32]; - random.nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); - } - private void resolveKey(SecureKey key) { when( keyResolver.resolveSigningKey( @@ -272,16 +266,13 @@ private String createCompactToken(String subject, SecureKey key) { .thenReturn( new SecretKeySpec( key.getBytes(), - SignatureAlgorithm.HS256.getValue() + SignatureAlgorithm.HS256.getJcaName() ) ); } //~--- fields --------------------------------------------------------------- - /** Field description */ - private final SecureRandom random = new SecureRandom(); - @InjectMocks private DAORealmHelperFactory helperFactory; diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java index 6dda005019..c005e7d381 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenBuilderTest.java @@ -44,7 +44,6 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import java.util.Random; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -56,6 +55,7 @@ import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; /** * Unit test for {@link JwtAccessTokenBuilder}. @@ -162,11 +162,4 @@ public class JwtAccessTokenBuilderTest { assertEquals("b", token.getCustom("a").get()); assertEquals("[\"repo:*\"]", token.getScope().toString()); } - - private SecureKey createSecureKey() { - byte[] bytes = new byte[32]; - new Random().nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); - } - } diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java new file mode 100644 index 0000000000..774677cde3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java @@ -0,0 +1,152 @@ +package sonia.scm.security; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.sql.Date; +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; +import java.util.Optional; + +import static java.time.Duration.ofMinutes; +import static java.time.temporal.ChronoUnit.SECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + +@SubjectAware( + username = "user", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +@RunWith(MockitoJUnitRunner.class) +public class JwtAccessTokenRefresherTest { + + private static final Instant NOW = Instant.now().truncatedTo(SECONDS); + private static final Instant TOKEN_CREATION = NOW.minus(ofMinutes(1)); + + @Rule + public ShiroRule shiro = new ShiroRule(); + + @Mock + private SecureKeyResolver keyResolver; + @Mock + private JwtAccessTokenRefreshStrategy refreshStrategy; + @Mock + private Clock refreshClock; + + private KeyGenerator keyGenerator = () -> "key"; + + private JwtAccessTokenRefresher refresher; + private JwtAccessTokenBuilder tokenBuilder; + + @Before + public void initKeyResolver() { + when(keyResolver.getSecureKey(any())).thenReturn(createSecureKey()); + + Clock creationClock = mock(Clock.class); + when(creationClock.instant()).thenReturn(TOKEN_CREATION); + tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, Collections.emptySet(), creationClock).create(); + + JwtAccessTokenBuilderFactory refreshBuilderFactory = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, Collections.emptySet(), refreshClock); + refresher = new JwtAccessTokenRefresher(refreshBuilderFactory, refreshStrategy, refreshClock); + when(refreshClock.instant()).thenReturn(NOW); + when(refreshStrategy.shouldBeRefreshed(any())).thenReturn(true); + + // set default expiration values + tokenBuilder + .expiresIn(5, MINUTES) + .refreshableFor(10, MINUTES); + } + + @Test + public void shouldNotRefreshTokenWithDisabledRefresh() { + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(0, MINUTES) + .build(); + + Optional refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenTokenExpired() { + Instant afterNormalExpiration = NOW.plus(ofMinutes(6)); + when(refreshClock.instant()).thenReturn(afterNormalExpiration); + JwtAccessToken oldToken = tokenBuilder.build(); + + Optional refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenRefreshExpired() { + Instant afterRefreshExpiration = Instant.now().plus(ofMinutes(2)); + when(refreshClock.instant()).thenReturn(afterRefreshExpiration); + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(1, MINUTES) + .build(); + + Optional refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenStrategyDoesNotSaySo() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(false); + + Optional refreshedToken = refresher.refresh(oldToken); + + assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldRefreshTokenWithParentId() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional refreshedTokenResult = refresher.refresh(oldToken); + + assertThat(refreshedTokenResult).isNotEmpty(); + JwtAccessToken refreshedToken = refreshedTokenResult.get(); + assertThat(refreshedToken.getParentKey()).get().isEqualTo("key"); + } + + @Test + public void shouldRefreshTokenWithSameExpiration() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional refreshedTokenResult = refresher.refresh(oldToken); + + assertThat(refreshedTokenResult).isNotEmpty(); + JwtAccessToken refreshedToken = refreshedTokenResult.get(); + assertThat(refreshedToken.getExpiration()).isEqualTo(Date.from(NOW.plus(ofMinutes(5)))); + } + + @Test + public void shouldRefreshTokenWithSameRefreshExpiration() { + JwtAccessToken oldToken = tokenBuilder.build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional refreshedTokenResult = refresher.refresh(oldToken); + + assertThat(refreshedTokenResult).isNotEmpty(); + JwtAccessToken refreshedToken = refreshedTokenResult.get(); + assertThat(refreshedToken.getRefreshExpiration()).get().isEqualTo(Date.from(TOKEN_CREATION.plus(ofMinutes(10)))); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java index 689fc4bb35..d4341f104e 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java @@ -56,6 +56,8 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; import static org.mockito.Mockito.*; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + import org.mockito.junit.MockitoJUnitRunner; /** @@ -214,12 +216,6 @@ public class JwtAccessTokenResolverTest { .compact(); } - private SecureKey createSecureKey() { - byte[] bytes = new byte[32]; - random.nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); - } - private void resolveKey(SecureKey key) { when( keyResolver.resolveSigningKey( @@ -230,7 +226,7 @@ public class JwtAccessTokenResolverTest { .thenReturn( new SecretKeySpec( key.getBytes(), - SignatureAlgorithm.HS256.getValue() + SignatureAlgorithm.HS256.getJcaName() ) ); } diff --git a/scm-webapp/src/test/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategyTest.java new file mode 100644 index 0000000000..122c1b5381 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/PercentageJwtAccessTokenRefreshStrategyTest.java @@ -0,0 +1,67 @@ +package sonia.scm.security; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; + +import static java.time.temporal.ChronoUnit.MINUTES; +import static java.time.temporal.ChronoUnit.SECONDS; +import static java.util.concurrent.TimeUnit.HOURS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static sonia.scm.security.SecureKeyTestUtil.createSecureKey; + +@SubjectAware( + username = "user", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +public class PercentageJwtAccessTokenRefreshStrategyTest { + + private static final Instant TOKEN_CREATION = Instant.now().truncatedTo(SECONDS); + + @Rule + public ShiroRule shiro = new ShiroRule(); + + private KeyGenerator keyGenerator = () -> "key"; + + private Clock refreshClock = mock(Clock.class); + + private JwtAccessTokenBuilder tokenBuilder; + private PercentageJwtAccessTokenRefreshStrategy refreshStrategy; + + @Before + public void initToken() { + SecureKeyResolver keyResolver = mock(SecureKeyResolver.class); + when(keyResolver.getSecureKey(any())).thenReturn(createSecureKey()); + + Clock creationClock = mock(Clock.class); + when(creationClock.instant()).thenReturn(TOKEN_CREATION); + + tokenBuilder = new JwtAccessTokenBuilderFactory(keyGenerator, keyResolver, Collections.emptySet(), creationClock).create(); + tokenBuilder.expiresIn(1, HOURS); + tokenBuilder.refreshableFor(1, HOURS); + + refreshStrategy = new PercentageJwtAccessTokenRefreshStrategy(refreshClock, 0.5F); + } + + @Test + public void shouldNotRefreshWhenTokenIsYoung() { + when(refreshClock.instant()).thenReturn(TOKEN_CREATION.plus(29, MINUTES)); + assertThat(refreshStrategy.shouldBeRefreshed(tokenBuilder.build())).isFalse(); + } + + @Test + public void shouldRefreshWhenTokenIsOld() { + when(refreshClock.instant()).thenReturn(TOKEN_CREATION.plus(31, MINUTES)); + assertThat(refreshStrategy.shouldBeRefreshed(tokenBuilder.build())).isTrue(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecureKeyTestUtil.java b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyTestUtil.java new file mode 100644 index 0000000000..3b9c95fd17 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/SecureKeyTestUtil.java @@ -0,0 +1,11 @@ +package sonia.scm.security; + +import java.security.SecureRandom; + +public class SecureKeyTestUtil { + public static SecureKey createSecureKey() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return new SecureKey(bytes, System.currentTimeMillis()); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java new file mode 100644 index 0000000000..945d8cf0d2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/web/security/TokenRefreshFilterTest.java @@ -0,0 +1,107 @@ +package sonia.scm.web.security; + +import org.apache.shiro.authc.AuthenticationToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.AccessTokenResolver; +import sonia.scm.security.BearerToken; +import sonia.scm.security.JwtAccessToken; +import sonia.scm.security.JwtAccessTokenRefresher; +import sonia.scm.web.WebTokenGenerator; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; + +import static java.util.Collections.singleton; +import static java.util.Optional.of; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class}) +class TokenRefreshFilterTest { + + @Mock + private Set tokenGenerators; + @Mock + private WebTokenGenerator tokenGenerator; + @Mock + private JwtAccessTokenRefresher refresher; + @Mock + private AccessTokenResolver resolver; + @Mock + private AccessTokenCookieIssuer issuer; + + @InjectMocks + private TokenRefreshFilter filter; + + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private FilterChain filterChain; + + @BeforeEach + void initGenerators() { + when(tokenGenerators.iterator()).thenReturn(singleton(tokenGenerator).iterator()); + } + + @Test + void shouldContinueChain() throws IOException, ServletException { + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(issuer, never()).authenticate(any(), any(), any()); + } + + @Test + void shouldNotRefreshNonBearerToken() throws IOException, ServletException { + AuthenticationToken token = mock(AuthenticationToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + + filter.doFilter(request, response, filterChain); + + verify(issuer, never()).authenticate(any(), any(), any()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotRefreshNonJwtToken() throws IOException, ServletException { + BearerToken token = mock(BearerToken.class); + JwtAccessToken jwtToken = mock(JwtAccessToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + when(resolver.resolve(token)).thenReturn(jwtToken); + + filter.doFilter(request, response, filterChain); + + verify(issuer, never()).authenticate(any(), any(), any()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldRefreshIfRefreshable() throws IOException, ServletException { + BearerToken token = mock(BearerToken.class); + JwtAccessToken jwtToken = mock(JwtAccessToken.class); + JwtAccessToken newJwtToken = mock(JwtAccessToken.class); + when(tokenGenerator.createToken(request)).thenReturn(token); + when(resolver.resolve(token)).thenReturn(jwtToken); + when(refresher.refresh(jwtToken)).thenReturn(of(newJwtToken)); + + filter.doFilter(request, response, filterChain); + + verify(issuer).authenticate(request, response, newJwtToken); + verify(filterChain).doFilter(request, response); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini index 9a39a2d46c..500325faf3 100644 --- a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini +++ b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini @@ -4,6 +4,7 @@ dent = secret, creator, heartOfGold, puzzle42 unpriv = secret crato = secret, creator community = secret, oss +user = secret, user [roles] admin = * @@ -11,3 +12,4 @@ creator = repository:create heartOfGold = "repository:read,modify,delete:hof" puzzle42 = "repository:read,write:p42" oss = "repository:pull" +user = *