diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LogoutRedirection.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LogoutRedirection.java new file mode 100644 index 0000000000..84fe2ddf7b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LogoutRedirection.java @@ -0,0 +1,12 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.plugin.ExtensionPoint; + +import java.net.URI; +import java.util.Optional; + +@ExtensionPoint(multi = false) +@FunctionalInterface +public interface LogoutRedirection { + Optional afterLogoutRedirectTo(); +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java index 6480e526b1..9fde8f7d98 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigMapper.java @@ -28,7 +28,7 @@ public abstract class GitRepositoryConfigMapper { @AfterMapping void appendLinks(@MappingTarget GitRepositoryConfigDto target, @Context Repository repository) { Links.Builder linksBuilder = linkingTo().self(self()); - if (RepositoryPermissions.modify(repository).isPermitted()) { + if (RepositoryPermissions.custom("git", repository).isPermitted()) { linksBuilder.single(link("update", update())); } target.add(linksBuilder.build()); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java index 292a934ea0..175caf8840 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/api/v2/resources/GitRepositoryConfigResource.java @@ -70,7 +70,7 @@ public class GitRepositoryConfigResource { }) public Response setRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name, GitRepositoryConfigDto dto) { Repository repository = getRepository(namespace, name); - RepositoryPermissions.modify(repository).check(); + RepositoryPermissions.custom("git", repository).check(); ConfigurationStore repositoryConfigStore = getStore(repository); GitRepositoryConfig config = repositoryConfigMapper.map(dto); repositoryConfigStore.set(config); diff --git a/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js b/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js index 177e662335..cdd08927e4 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js @@ -8,11 +8,10 @@ type Props = { repository: Repository, // context props - t: (string) => string + t: string => string }; class CloneInformation extends React.Component { - render() { const { url, repository, t } = this.props; @@ -51,7 +50,6 @@ class CloneInformation extends React.Component { ); } - } export default translate("plugins")(CloneInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitBranchInformation.js b/scm-plugins/scm-git-plugin/src/main/js/GitBranchInformation.js new file mode 100644 index 0000000000..b4c1873e9f --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/js/GitBranchInformation.js @@ -0,0 +1,30 @@ +//@flow +import React from "react"; +import type { Branch } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; + +type Props = { + branch: Branch, + t: string => string +}; + +class GitBranchInformation extends React.Component { + render() { + const { branch, t } = this.props; + + return ( +
+

{t("scm-git-plugin.information.fetch")}

+
+          git fetch
+        
+

{t("scm-git-plugin.information.checkout")}

+
+          git checkout {branch.name}
+        
+
+ ); + } +} + +export default translate("plugins")(GitBranchInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js index 692611510f..bc7e51102a 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js +++ b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js @@ -10,7 +10,7 @@ type Configuration = { gcExpression?: string, nonFastForwardDisallowed: boolean, _links: Links -} +}; type Props = { initialConfiguration: Configuration, @@ -19,25 +19,24 @@ type Props = { onConfigurationChange: (Configuration, boolean) => void, // context props - t: (string) => string -} + t: string => string +}; -type State = Configuration & { - -} +type State = Configuration & {}; class GitConfigurationForm extends React.Component { - constructor(props: Props) { super(props); this.state = { ...props.initialConfiguration }; } - handleChange = (value: any, name: string) => { - this.setState({ - [name]: value - }, () => this.props.onConfigurationChange(this.state, true)); + this.setState( + { + [name]: value + }, + () => this.props.onConfigurationChange(this.state, true) + ); }; render() { @@ -46,24 +45,25 @@ class GitConfigurationForm extends React.Component { return ( <> - - ); } - } export default translate("plugins")(GitConfigurationForm); diff --git a/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js b/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js index fabb677045..96085ec2f2 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js +++ b/scm-plugins/scm-git-plugin/src/main/js/RepositoryConfig.js @@ -121,6 +121,7 @@ class RepositoryConfig extends React.Component { if (!(loadingBranches || loadingDefaultBranch)) { return ( <> +
{this.renderBranchChangedNotification()}
@@ -133,7 +134,6 @@ class RepositoryConfig extends React.Component { /> { submitButton } -
); } else { diff --git a/scm-plugins/scm-git-plugin/src/main/js/index.js b/scm-plugins/scm-git-plugin/src/main/js/index.js index 43e3950beb..0dafbe4227 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/index.js +++ b/scm-plugins/scm-git-plugin/src/main/js/index.js @@ -6,6 +6,7 @@ import GitAvatar from "./GitAvatar"; import {ConfigurationBinder as cfgBinder} from "@scm-manager/ui-components"; import GitGlobalConfiguration from "./GitGlobalConfiguration"; +import GitBranchInformation from "./GitBranchInformation"; import GitMergeInformation from "./GitMergeInformation"; import RepositoryConfig from "./RepositoryConfig"; @@ -20,6 +21,11 @@ binder.bind( ProtocolInformation, gitPredicate ); +binder.bind( + "repos.branch-details.information", + GitBranchInformation, + gitPredicate +); binder.bind( "repos.repository-merge.information", GitMergeInformation, diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json index 578d859c8e..e150ceb42b 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json @@ -1,9 +1,11 @@ { "scm-git-plugin": { "information": { - "clone" : "Repository klonen", - "create" : "Neues Repository erstellen", - "replace" : "Ein bestehendes Repository aktualisieren", + "clone": "Repository klonen", + "create": "Neues Repository erstellen", + "replace": "Ein bestehendes Repository aktualisieren", + "fetch": "Remote-Änderungen herunterladen", + "checkout": "Branch wechseln", "merge": { "heading": "Merge des Source Branch in den Target Branch", "checkout": "1. Sicherstellen, dass der Workspace aufgeräumt ist und der Target Branch ausgecheckt wurde.", @@ -37,7 +39,7 @@ "success": "Der standard Branch wurde geändert!" } }, - "permissions" : { + "permissions": { "configuration": { "read,write": { "git": { diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index bea0a08dc9..22eb46b9f8 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -4,6 +4,8 @@ "clone": "Clone the repository", "create": "Create a new repository", "replace": "Push an existing repository", + "fetch": "Get remote changes", + "checkout": "Switch branch", "merge": { "heading": "How to merge source branch into target branch", "checkout": "1. Make sure your workspace is clean and checkout target branch", diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini index f0ffbe4ac5..083c685dbe 100644 --- a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/configuration/shiro.ini @@ -10,4 +10,4 @@ writer = configuration:write:git readerWriter = configuration:*:git,repository:*:id admin = * repoRead = repository:read:* -repoWrite = repository:modify:* +repoWrite = repository:modify:*,repository:git:* diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgBranchInformation.js b/scm-plugins/scm-hg-plugin/src/main/js/HgBranchInformation.js new file mode 100644 index 0000000000..358a682054 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/js/HgBranchInformation.js @@ -0,0 +1,30 @@ +//@flow +import React from "react"; +import type { Branch } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; + +type Props = { + branch: Branch, + t: string => string +}; + +class HgBranchInformation extends React.Component { + render() { + const { branch, t } = this.props; + + return ( +
+

{t("scm-hg-plugin.information.fetch")}

+
+          hg pull
+        
+

{t("scm-hg-plugin.information.checkout")}

+
+          hg update {branch.name}
+        
+
+ ); + } +} + +export default translate("plugins")(HgBranchInformation); diff --git a/scm-plugins/scm-hg-plugin/src/main/js/index.js b/scm-plugins/scm-hg-plugin/src/main/js/index.js index a1fa72f5bd..9df4512d10 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/index.js +++ b/scm-plugins/scm-hg-plugin/src/main/js/index.js @@ -4,14 +4,29 @@ import ProtocolInformation from "./ProtocolInformation"; import HgAvatar from "./HgAvatar"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; import HgGlobalConfiguration from "./HgGlobalConfiguration"; +import HgBranchInformation from "./HgBranchInformation"; const hgPredicate = (props: Object) => { return props.repository && props.repository.type === "hg"; }; -binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate); +binder.bind( + "repos.repository-details.information", + ProtocolInformation, + hgPredicate +); +binder.bind( + "repos.branch-details.information", + HgBranchInformation, + hgPredicate +); binder.bind("repos.repository-avatar", HgAvatar, hgPredicate); // bind global configuration -cfgBinder.bindGlobal("/hg", "scm-hg-plugin.config.link", "hgConfig", HgGlobalConfiguration); +cfgBinder.bindGlobal( + "/hg", + "scm-hg-plugin.config.link", + "hgConfig", + HgGlobalConfiguration +); diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json index 63a8cc8a98..d32847a3af 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json @@ -3,7 +3,9 @@ "information": { "clone" : "Repository klonen", "create" : "Neues Repository erstellen", - "replace" : "Ein bestehendes Repository aktualisieren" + "replace" : "Ein bestehendes Repository aktualisieren", + "fetch": "Remote-Änderungen herunterladen", + "checkout": "Branch wechseln" }, "config": { "link": "Mercurial", diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json index a5d05d5796..3792bd4a47 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json @@ -3,7 +3,9 @@ "information": { "clone" : "Clone the repository", "create" : "Create a new repository", - "replace" : "Push an existing repository" + "replace" : "Push an existing repository", + "fetch": "Get remote changes", + "checkout": "Switch branch" }, "config": { "link": "Mercurial", diff --git a/scm-ui-components/packages/ui-components/package.json b/scm-ui-components/packages/ui-components/package.json index c1a179315c..991142e338 100644 --- a/scm-ui-components/packages/ui-components/package.json +++ b/scm-ui-components/packages/ui-components/package.json @@ -30,9 +30,9 @@ "@scm-manager/ui-types": "2.0.0-SNAPSHOT", "classnames": "^2.2.6", "moment": "^2.22.2", - "react": "^16.5.2", + "react": "^16.8.6", + "react-dom": "^16.8.6", "react-diff-view": "^1.8.1", - "react-dom": "^16.5.2", "react-i18next": "^7.11.0", "react-jss": "^8.6.1", "react-router-dom": "^4.3.1", @@ -63,4 +63,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/scm-ui-components/packages/ui-components/src/MailLink.js b/scm-ui-components/packages/ui-components/src/MailLink.js index 7d009cde85..e18264e85c 100644 --- a/scm-ui-components/packages/ui-components/src/MailLink.js +++ b/scm-ui-components/packages/ui-components/src/MailLink.js @@ -11,7 +11,7 @@ class MailLink extends React.Component { if (!address) { return null; } - return {address}; + return {address}; } } diff --git a/scm-ui-components/packages/ui-components/src/forms/Select.js b/scm-ui-components/packages/ui-components/src/forms/Select.js index 38e1cdab33..5d37e3d631 100644 --- a/scm-ui-components/packages/ui-components/src/forms/Select.js +++ b/scm-ui-components/packages/ui-components/src/forms/Select.js @@ -15,7 +15,8 @@ type Props = { value?: string, onChange: (value: string, name?: string) => void, loading?: boolean, - helpText?: string + helpText?: string, + disabled?: boolean }; class Select extends React.Component { @@ -34,7 +35,7 @@ class Select extends React.Component { }; render() { - const { options, value, label, helpText, loading } = this.props; + const { options, value, label, helpText, loading, disabled } = this.props; const loadingClass = loading ? "is-loading" : ""; @@ -51,6 +52,7 @@ class Select extends React.Component { }} value={value} onChange={this.handleInput} + disabled={disabled} > {options.map(opt => { return ( diff --git a/scm-ui-components/packages/ui-components/src/repos/DiffFile.js b/scm-ui-components/packages/ui-components/src/repos/DiffFile.js index ccc07f2ea1..64450659f0 100644 --- a/scm-ui-components/packages/ui-components/src/repos/DiffFile.js +++ b/scm-ui-components/packages/ui-components/src/repos/DiffFile.js @@ -11,6 +11,7 @@ import { import injectSheets from "react-jss"; import classNames from "classnames"; import { translate } from "react-i18next"; +import {Button} from "../buttons"; const styles = { panel: { @@ -39,14 +40,16 @@ type Props = DiffObjectProps & { }; type State = { - collapsed: boolean + collapsed: boolean, + sideBySide: boolean }; class DiffFile extends React.Component { constructor(props: Props) { super(props); this.state = { - collapsed: false + collapsed: false, + sideBySide: false }; } @@ -56,6 +59,12 @@ class DiffFile extends React.Component { })); }; + toggleSideBySide = () => { + this.setState(state => ({ + sideBySide: !state.sideBySide + })); + }; + setCollapse = (collapsed: boolean) => { this.setState({ collapsed @@ -149,10 +158,10 @@ class DiffFile extends React.Component { file, fileControlFactory, fileAnnotationFactory, - sideBySide, - classes + classes, + t } = this.props; - const { collapsed } = this.state; + const { collapsed, sideBySide } = this.state; const viewType = sideBySide ? "split" : "unified"; let body = null; @@ -173,14 +182,10 @@ class DiffFile extends React.Component { } const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null; - return ( -
+ return
-
+
{this.renderFileTitle(file)} @@ -189,12 +194,21 @@ class DiffFile extends React.Component { {this.renderChangeTag(file)}
-
{fileControls}
+
+ + {fileControls} +
{body} -
- ); +
; } } diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js index 4035cbd899..b240bc1af2 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetAuthor.js @@ -2,13 +2,13 @@ import React from "react"; import type { Changeset } from "@scm-manager/ui-types"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import {translate} from "react-i18next"; +import { translate } from "react-i18next"; type Props = { changeset: Changeset, // context props - t: (string) => string + t: string => string }; class ChangesetAuthor extends React.Component { @@ -28,7 +28,10 @@ class ChangesetAuthor extends React.Component { renderWithMail(name: string, mail: string) { const { t } = this.props; return ( - + {name} ); @@ -44,7 +47,7 @@ class ChangesetAuthor extends React.Component { props={{ changeset: this.props.changeset }} renderAll={true} /> - + ); } } diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js index de05efb46e..04df747c1a 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js @@ -11,11 +11,10 @@ type Props = { changeset: Changeset, // context props - t: (string) => string -} + t: string => string +}; class ChangesetButtonGroup extends React.Component { - render() { const { repository, changeset, t } = this.props; @@ -26,7 +25,7 @@ class ChangesetButtonGroup extends React.Component { + + + ); + } +} + +export default translate("repos")(BranchButtonGroup); diff --git a/scm-ui/src/repos/branches/components/BranchDetail.js b/scm-ui/src/repos/branches/components/BranchDetail.js new file mode 100644 index 0000000000..86b142d9da --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchDetail.js @@ -0,0 +1,33 @@ +//@flow +import React from "react"; +import type { Repository, Branch } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; +import BranchButtonGroup from "./BranchButtonGroup"; +import DefaultBranchTag from "./DefaultBranchTag"; + +type Props = { + repository: Repository, + branch: Branch, + // context props + t: string => string +}; + +class BranchDetail extends React.Component { + render() { + const { repository, branch, t } = this.props; + + return ( +
+
+ {t("branch.name")} {branch.name}{" "} + +
+
+ +
+
+ ); + } +} + +export default translate("repos")(BranchDetail); diff --git a/scm-ui/src/repos/branches/components/BranchForm.js b/scm-ui/src/repos/branches/components/BranchForm.js new file mode 100644 index 0000000000..1219511345 --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchForm.js @@ -0,0 +1,125 @@ +// @flow +import React from "react"; +import { translate } from "react-i18next"; +import type { Repository, Branch, BranchRequest } from "@scm-manager/ui-types"; +import { + Select, + InputField, + SubmitButton, + validation as validator +} from "@scm-manager/ui-components"; +import { orderBranches } from "../util/orderBranches"; + +type Props = { + submitForm: BranchRequest => void, + repository: Repository, + branches: Branch[], + loading?: boolean, + transmittedName?: string, + disabled?: boolean, + t: string => string +}; + +type State = { + source?: string, + name?: string, + nameValidationError: boolean +}; + +class BranchForm extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { + nameValidationError: false, + name: props.transmittedName + }; + } + + isFalsy(value) { + return !value; + } + + isValid = () => { + const { source, name } = this.state; + return !( + this.state.nameValidationError || + this.isFalsy(source) || + this.isFalsy(name) + ); + }; + + submit = (event: Event) => { + event.preventDefault(); + if (this.isValid()) { + this.props.submitForm({ + name: this.state.name, + parent: this.state.source + }); + } + }; + + render() { + const { t, branches, loading, transmittedName, disabled } = this.props; + const { name } = this.state; + orderBranches(branches); + const options = branches.map(branch => ({ + label: branch.name, + value: branch.name + })); + + return ( + <> +
+
+
+ diff --git a/scm-ui/src/users/components/table/UserTable.js b/scm-ui/src/users/components/table/UserTable.js index 89e6e5df83..8c012f11f4 100644 --- a/scm-ui/src/users/components/table/UserTable.js +++ b/scm-ui/src/users/components/table/UserTable.js @@ -9,8 +9,6 @@ type Props = { users: User[] }; -; - class UserTable extends React.Component { render() { const { users, t } = this.props; diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js index 546eb635cb..68d0864d59 100644 --- a/scm-ui/src/users/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -19,6 +19,7 @@ import { PageActions, Button, CreateButton, + Notification, LinkPaginator, getPageFromMatch } from "@scm-manager/ui-components"; @@ -88,14 +89,26 @@ class Users extends React.Component { history.push("/users/?q=" + filter); }} > - - {this.renderPaginator()} + {this.renderUserTable()} {this.renderCreateButton()} {this.renderPageActionCreateButton()} ); } + renderUserTable() { + const { users, t } = this.props; + if (users && users.length > 0) { + return ( + <> + + {this.renderPaginator()} + + ); + } + return {t("users.noUsers")}; + } + renderPaginator = () => { const { list, page } = this.props; if (list) { diff --git a/scm-ui/yarn.lock b/scm-ui/yarn.lock index ac7c7b0122..3986930a35 100644 --- a/scm-ui/yarn.lock +++ b/scm-ui/yarn.lock @@ -698,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.26": - version "0.0.26" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.26.tgz#4676a7079b781b33fa1989c6643205c3559b1f66" +"@scm-manager/ui-bundler@^0.0.27": + version "0.0.27" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.27.tgz#3ed2c7826780b9a1a9ea90464332640cfb5d54b5" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -5669,6 +5669,10 @@ memoize-one@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" +memoize-one@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.4.tgz#005928aced5c43d890a4dfab18ca908b0ec92cbc" + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java index 47918080ab..deab53a708 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java @@ -16,6 +16,8 @@ import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.net.URI; +import java.util.Optional; @Path(AuthenticationResource.PATH) @AllowAnonymousAccess @@ -28,6 +30,9 @@ public class AuthenticationResource { private final AccessTokenBuilderFactory tokenBuilderFactory; private final AccessTokenCookieIssuer cookieIssuer; + @Inject(optional = true) + private LogoutRedirection logoutRedirection; + @Inject public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) { @@ -35,7 +40,6 @@ public class AuthenticationResource { this.cookieIssuer = cookieIssuer; } - @POST @Path("access_token") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -121,6 +125,7 @@ public class AuthenticationResource { @DELETE @Path("access_token") + @Produces(MediaType.APPLICATION_JSON) @StatusCodes({ @ResponseCode(code = 204, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") @@ -135,7 +140,19 @@ public class AuthenticationResource { cookieIssuer.invalidate(request, response); // TODO anonymous access ?? - return Response.noContent().build(); + if (logoutRedirection == null) { + return Response.noContent().build(); + } else { + Optional uri = logoutRedirection.afterLogoutRedirectTo(); + if (uri.isPresent()) { + return Response.ok(new RedirectAfterLogoutDto(uri.get().toASCIIString())).build(); + } else { + return Response.noContent().build(); + } + } } + void setLogoutRedirection(LogoutRedirection logoutRedirection) { + this.logoutRedirection = logoutRedirection; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java index f0edbf79fb..9e7353b4b7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -79,15 +79,16 @@ public class BranchRootResource { @ResponseCode(code = 500, condition = "internal server error") }) public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("branch") String branchName) throws IOException { - try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { Branches branches = repositoryService.getBranchesCommand().getBranches(); return branches.getBranches() .stream() .filter(branch -> branchName.equals(branch.getName())) .findFirst() - .map(branch -> branchToDtoMapper.map(branch, new NamespaceAndName(namespace, name))) + .map(branch -> branchToDtoMapper.map(branch, namespaceAndName)) .map(Response::ok) - .orElse(Response.status(Response.Status.NOT_FOUND)) + .orElseThrow(() -> notFound(entity("branch", branchName).in(namespaceAndName))) .build(); } catch (CommandNotSupportedException ex) { return Response.status(Response.Status.BAD_REQUEST).build(); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RedirectAfterLogoutDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RedirectAfterLogoutDto.java new file mode 100644 index 0000000000..a6402a0190 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RedirectAfterLogoutDto.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RedirectAfterLogoutDto { + private String logoutRedirect; +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java index 1123dc94ce..177f975971 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java @@ -23,10 +23,18 @@ import sonia.scm.security.DefaultAccessTokenCookieIssuer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.MediaType; +import java.io.UnsupportedEncodingException; +import java.net.URI; import java.net.URISyntaxException; import java.util.Date; +import java.util.Optional; +import static java.net.URI.create; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -49,6 +57,8 @@ public class AuthenticationResourceTest { private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); + private MockHttpResponse response = new MockHttpResponse(); + private static final String AUTH_JSON_TRILLIAN = "{\n" + "\t\"cookie\": true,\n" + "\t\"grant_type\": \"password\",\n" + @@ -101,9 +111,11 @@ public class AuthenticationResourceTest { "}" ); + private AuthenticationResource authenticationResource; + @Before public void prepareEnvironment() { - AuthenticationResource authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer); + authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer); dispatcher.getRegistry().addSingletonResource(authenticationResource); AccessToken accessToken = mock(AccessToken.class); @@ -123,7 +135,6 @@ public class AuthenticationResourceTest { public void shouldAuthCorrectly() throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -134,7 +145,6 @@ public class AuthenticationResourceTest { public void shouldAuthCorrectlyWithFormencodedData() throws URISyntaxException { MockHttpRequest request = getMockHttpRequestUrlEncoded(AUTH_FORMENCODED_TRILLIAN); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -146,7 +156,6 @@ public class AuthenticationResourceTest { public void shouldNotAuthUserWithWrongPassword() throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN_WRONG_PW); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -156,7 +165,6 @@ public class AuthenticationResourceTest { @Test public void shouldNotAuthNonexistingUser() throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(AUTH_JSON_NOT_EXISTING_USER); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -187,16 +195,36 @@ public class AuthenticationResourceTest { @SubjectAware(username = "trillian", password = "secret") public void shouldSuccessfullyLogoutUser() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); } + @Test + public void shouldHandleLogoutRedirection() throws URISyntaxException, UnsupportedEncodingException { + authenticationResource.setLogoutRedirection(() -> of(create("http://example.com/cas/logout"))); + + MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertThat(response.getContentAsString(), containsString("http://example.com/cas/logout")); + } + + @Test + public void shouldHandleDisabledLogoutRedirection() throws URISyntaxException { + authenticationResource.setLogoutRedirection(Optional::empty); + + MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + } private void shouldReturnBadRequest(String requestBody) throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(requestBody); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -218,5 +246,4 @@ public class AuthenticationResourceTest { request.contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); return request; } - } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java index 9fb20c0922..2c83d5e3e1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java @@ -129,6 +129,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { dispatcher.invoke(request, response); assertEquals(404, response.getStatus()); + assertEquals("application/vnd.scmm-error+json;v=2", response.getOutputHeaders().getFirst("Content-Type")); } @Test