mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-09 15:05:44 +01:00
Merged in feature/plugin_center (pull request #284)
Feature/plugin center
This commit is contained in:
@@ -37,6 +37,8 @@ public class VndMediaType {
|
||||
public static final String REPOSITORY_VERB_COLLECTION = PREFIX + "repositoryVerbCollection" + SUFFIX;
|
||||
public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX;
|
||||
public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX;
|
||||
public static final String PLUGIN = PREFIX + "plugin" + SUFFIX;
|
||||
public static final String PLUGIN_COLLECTION = PREFIX + "pluginCollection" + SUFFIX;
|
||||
public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX;
|
||||
public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX;
|
||||
@SuppressWarnings("squid:S2068")
|
||||
|
||||
87
scm-ui-components/packages/ui-components/src/CardColumn.js
Normal file
87
scm-ui-components/packages/ui-components/src/CardColumn.js
Normal file
@@ -0,0 +1,87 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const styles = {
|
||||
inner: {
|
||||
position: "relative",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1
|
||||
},
|
||||
innerLink: {
|
||||
pointerEvents: "all"
|
||||
},
|
||||
centerImage: {
|
||||
marginTop: "0.8em",
|
||||
marginLeft: "1em !important"
|
||||
},
|
||||
flexFullHeight: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignSelf: "stretch"
|
||||
},
|
||||
content: {
|
||||
display: "flex",
|
||||
flexGrow: 1
|
||||
},
|
||||
footer: {
|
||||
display: "flex",
|
||||
marginTop: "auto",
|
||||
paddingBottom: "1.5rem"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
description: string,
|
||||
avatar: React.Node,
|
||||
footerLeft: React.Node,
|
||||
footerRight: React.Node,
|
||||
link: string,
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
class CardColumn extends React.Component<Props> {
|
||||
createLink = () => {
|
||||
const { link } = this.props;
|
||||
if (link) {
|
||||
return <Link className="overlay-column" to={link} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { avatar, title, description, footerLeft, footerRight, classes } = this.props;
|
||||
const link = this.createLink();
|
||||
return (
|
||||
<>
|
||||
{link}
|
||||
<article className={classNames("media", classes.inner)}>
|
||||
<figure className={classNames(classes.centerImage, "media-left")}>
|
||||
{avatar}
|
||||
</figure>
|
||||
<div className={classNames("media-content", "text-box", classes.flexFullHeight)}>
|
||||
<div className={classes.content}>
|
||||
<div className="content shorten-text">
|
||||
<p className="is-marginless">
|
||||
<strong>{title}</strong>
|
||||
</p>
|
||||
<p className="shorten-text">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames(classes.footer, "level")}>
|
||||
<div className="level-left is-hidden-mobile">{footerLeft}</div>
|
||||
<div className="level-right is-mobile">{footerRight}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(CardColumn);
|
||||
103
scm-ui-components/packages/ui-components/src/CardColumnGroup.js
Normal file
103
scm-ui-components/packages/ui-components/src/CardColumnGroup.js
Normal file
@@ -0,0 +1,103 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
|
||||
const styles = {
|
||||
pointer: {
|
||||
cursor: "pointer",
|
||||
fontSize: "1.5rem"
|
||||
},
|
||||
repoGroup: {
|
||||
marginBottom: "1em"
|
||||
},
|
||||
wrapper: {
|
||||
padding: "0 0.75rem"
|
||||
},
|
||||
clearfix: {
|
||||
clear: "both"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
elements: React.Node[],
|
||||
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean
|
||||
};
|
||||
|
||||
class CardColumnGroup extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false
|
||||
};
|
||||
}
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState(prevState => ({
|
||||
collapsed: !prevState.collapsed
|
||||
}));
|
||||
};
|
||||
|
||||
isLastEntry = (array: React.Node[], index: number) => {
|
||||
return index === array.length - 1;
|
||||
};
|
||||
|
||||
isLengthOdd = (array: React.Node[]) => {
|
||||
return array.length % 2 !== 0;
|
||||
};
|
||||
|
||||
isFullSize = (array: React.Node[], index: number) => {
|
||||
return this.isLastEntry(array, index) && this.isLengthOdd(array);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, elements, classes } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
|
||||
let content = null;
|
||||
if (!collapsed) {
|
||||
content = elements.map((entry, index) => {
|
||||
const fullColumnWidth = this.isFullSize(elements, index);
|
||||
const sizeClass = fullColumnWidth ? "is-full" : "is-half";
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"box",
|
||||
"box-link-shadow",
|
||||
"column",
|
||||
"is-clipped",
|
||||
sizeClass
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
{entry}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className={classes.repoGroup}>
|
||||
<h2>
|
||||
<span className={classes.pointer} onClick={this.toggleCollapse}>
|
||||
<i className={classNames("fa", icon)} /> {name}
|
||||
</span>
|
||||
</h2>
|
||||
<hr />
|
||||
<div className={classNames("columns", "is-multiline", classes.wrapper)}>
|
||||
{content}
|
||||
</div>
|
||||
<div className={classes.clearfix} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(CardColumnGroup);
|
||||
@@ -25,7 +25,7 @@ class LinkPaginator extends React.Component<Props> {
|
||||
renderFirstButton() {
|
||||
return (
|
||||
<Button
|
||||
className={"pagination-link"}
|
||||
className="pagination-link"
|
||||
label={"1"}
|
||||
disabled={false}
|
||||
link={this.addFilterToLink("1")}
|
||||
@@ -69,7 +69,7 @@ class LinkPaginator extends React.Component<Props> {
|
||||
const { collection } = this.props;
|
||||
return (
|
||||
<Button
|
||||
className={"pagination-link"}
|
||||
className="pagination-link"
|
||||
label={`${collection.pageTotal}`}
|
||||
disabled={false}
|
||||
link={this.addFilterToLink(`${collection.pageTotal}`)}
|
||||
|
||||
@@ -17,7 +17,7 @@ class StatePaginator extends React.Component<Props> {
|
||||
renderFirstButton() {
|
||||
return (
|
||||
<Button
|
||||
className={"pagination-link"}
|
||||
className="pagination-link"
|
||||
label={"1"}
|
||||
disabled={false}
|
||||
action={() => this.updateCurrentPage(1)}
|
||||
@@ -35,7 +35,7 @@ class StatePaginator extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={"pagination-previous"}
|
||||
className="pagination-previous"
|
||||
label={label ? label : previousPage.toString()}
|
||||
disabled={!this.hasLink("prev")}
|
||||
action={() => this.updateCurrentPage(previousPage)}
|
||||
@@ -53,7 +53,7 @@ class StatePaginator extends React.Component<Props> {
|
||||
const nextPage = page + 1;
|
||||
return (
|
||||
<Button
|
||||
className={"pagination-next"}
|
||||
className="pagination-next"
|
||||
label={label ? label : nextPage.toString()}
|
||||
disabled={!this.hasLink("next")}
|
||||
action={() => this.updateCurrentPage(nextPage)}
|
||||
@@ -65,7 +65,7 @@ class StatePaginator extends React.Component<Props> {
|
||||
const { collection } = this.props;
|
||||
return (
|
||||
<Button
|
||||
className={"pagination-link"}
|
||||
className="pagination-link"
|
||||
label={`${collection.pageTotal}`}
|
||||
disabled={false}
|
||||
action={() => this.updateCurrentPage(collection.pageTotal)}
|
||||
|
||||
@@ -34,6 +34,8 @@ export { default as MarkdownView } from "./MarkdownView";
|
||||
export { default as SyntaxHighlighter } from "./SyntaxHighlighter";
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||
export { default as OverviewPageActions } from "./OverviewPageActions.js";
|
||||
export { default as CardColumnGroup } from "./CardColumnGroup";
|
||||
export { default as CardColumn } from "./CardColumn";
|
||||
|
||||
export { apiClient } from "./apiclient.js";
|
||||
export * from "./errors";
|
||||
|
||||
22
scm-ui-components/packages/ui-types/src/Plugin.js
Normal file
22
scm-ui-components/packages/ui-types/src/Plugin.js
Normal file
@@ -0,0 +1,22 @@
|
||||
//@flow
|
||||
import type { Collection, Links } from "./hal";
|
||||
|
||||
export type Plugin = {
|
||||
name: string,
|
||||
type: string,
|
||||
version: string,
|
||||
author: string,
|
||||
description?: string,
|
||||
_links: Links
|
||||
};
|
||||
|
||||
export type PluginCollection = Collection & {
|
||||
_embedded: {
|
||||
plugins: Plugin[] | string[]
|
||||
}
|
||||
};
|
||||
|
||||
export type PluginGroup = {
|
||||
name: string,
|
||||
plugins: Plugin[]
|
||||
};
|
||||
@@ -25,6 +25,8 @@ export type { SubRepository, File } from "./Sources";
|
||||
|
||||
export type { SelectValue, AutocompleteObject } from "./Autocomplete";
|
||||
|
||||
export type { Plugin, PluginCollection, PluginGroup } from "./Plugin";
|
||||
|
||||
export type { RepositoryRole } from "./RepositoryRole";
|
||||
|
||||
export type { NamespaceStrategies } from "./NamespaceStrategies";
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
"currentAppVersion": "Aktuelle Software-Versionsnummer"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
"installedSubtitle": "Installierte Plugins",
|
||||
"availableSubtitle": "Verfügbare Plugins",
|
||||
"menu": {
|
||||
"pluginsNavLink": "Plugins",
|
||||
"installedNavLink": "Installiert",
|
||||
"availableNavLink": "Verfügbar"
|
||||
},
|
||||
"noPlugins": "Keine Plugins gefunden."
|
||||
},
|
||||
"repositoryRole": {
|
||||
"navLink": "Berechtigungsrollen",
|
||||
"title": "Berechtigungsrollen",
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
"currentAppVersion": "Current Application Version"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
"installedSubtitle": "Installed Plugins",
|
||||
"availableSubtitle": "Available Plugins",
|
||||
"menu": {
|
||||
"pluginsNavLink": "Plugins",
|
||||
"installedNavLink": "Installed",
|
||||
"availableNavLink": "Available"
|
||||
},
|
||||
"noPlugins": "No plugins found."
|
||||
},
|
||||
"repositoryRole": {
|
||||
"navLink": "Permission Roles",
|
||||
"title": "Permission Roles",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Links } from "@scm-manager/ui-types";
|
||||
import { Page, Navigation, NavLink, Section, SubNavigation } from "@scm-manager/ui-components";
|
||||
import { getLinks } from "../../modules/indexResource";
|
||||
import AdminDetails from "./AdminDetails";
|
||||
import PluginsOverview from "../plugins/containers/PluginsOverview";
|
||||
import GlobalConfig from "./GlobalConfig";
|
||||
import RepositoryRoles from "../roles/containers/RepositoryRoles";
|
||||
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
|
||||
@@ -62,6 +63,35 @@ class Admin extends React.Component<Props> {
|
||||
<Redirect exact from={url} to={`${url}/info`} />
|
||||
<Route path={`${url}/info`} exact component={AdminDetails} />
|
||||
<Route path={`${url}/settings/general`} exact component={GlobalConfig} />
|
||||
<Redirect exact from={`${url}/plugins`} to={`${url}/plugins/installed/`} />
|
||||
<Route
|
||||
path={`${url}/plugins/installed`}
|
||||
exact
|
||||
render={() => (
|
||||
<PluginsOverview baseUrl={`${url}/plugins/installed`} installed={true} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/plugins/installed/:page`}
|
||||
exact
|
||||
render={() => (
|
||||
<PluginsOverview baseUrl={`${url}/plugins/installed`} installed={true} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/plugins/available`}
|
||||
exact
|
||||
render={() => (
|
||||
<PluginsOverview baseUrl={`${url}/plugins/available`} installed={false} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/plugins/available/:page`}
|
||||
exact
|
||||
render={() => (
|
||||
<PluginsOverview baseUrl={`${url}/plugins/available`} installed={false} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/role/:role`}
|
||||
render={() => (
|
||||
@@ -106,6 +136,24 @@ class Admin extends React.Component<Props> {
|
||||
icon="fas fa-info-circle"
|
||||
label={t("admin.menu.informationNavLink")}
|
||||
/>
|
||||
{
|
||||
links.plugins &&
|
||||
<SubNavigation
|
||||
to={`${url}/plugins/`}
|
||||
icon="fas fa-puzzle-piece"
|
||||
label={t("plugins.menu.pluginsNavLink")}
|
||||
>
|
||||
<NavLink
|
||||
to={`${url}/plugins/installed/`}
|
||||
label={t("plugins.menu.installedNavLink")}
|
||||
/>
|
||||
{/* Activate this again after available plugins page is created */}
|
||||
{/*<NavLink*/}
|
||||
{/* to={`${url}/plugins/available/`}*/}
|
||||
{/* label={t("plugins.menu.availableNavLink")}*/}
|
||||
{/*/>*/}
|
||||
</SubNavigation>
|
||||
}
|
||||
<NavLink
|
||||
to={`${url}/roles/`}
|
||||
icon="fas fa-user-shield"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import {Loading, Subtitle} from "@scm-manager/ui-components";
|
||||
import {getAppVersion} from "../../modules/indexResource";
|
||||
import { connect } from "react-redux";
|
||||
import Title from "@scm-manager/ui-components/src/layout/Title";
|
||||
import { translate } from "react-i18next";
|
||||
import { Loading, Title, Subtitle } from "@scm-manager/ui-components";
|
||||
import { getAppVersion } from "../../modules/indexResource";
|
||||
|
||||
type Props = {
|
||||
loading: boolean,
|
||||
@@ -24,10 +23,12 @@ class AdminDetails extends React.Component<Props> {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return <>
|
||||
return (
|
||||
<>
|
||||
<Title title={t("admin.information.currentAppVersion")} />
|
||||
<Subtitle subtitle={this.props.version} />
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
scm-ui/src/admin/plugins/components/PluginAvatar.js
Normal file
22
scm-ui/src/admin/plugins/components/PluginAvatar.js
Normal file
@@ -0,0 +1,22 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import type { Plugin } from "@scm-manager/ui-types";
|
||||
import { Image } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = {
|
||||
plugin: Plugin
|
||||
};
|
||||
|
||||
export default class PluginAvatar extends React.Component<Props> {
|
||||
render() {
|
||||
const { plugin } = this.props;
|
||||
return (
|
||||
<p className="image is-64x64">
|
||||
<ExtensionPoint name="plugins.plugin-avatar" props={{ plugin }}>
|
||||
<Image src="/images/blib.jpg" alt="Logo" />
|
||||
</ExtensionPoint>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
44
scm-ui/src/admin/plugins/components/PluginEntry.js
Normal file
44
scm-ui/src/admin/plugins/components/PluginEntry.js
Normal file
@@ -0,0 +1,44 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Plugin } from "@scm-manager/ui-types";
|
||||
import { CardColumn } from "@scm-manager/ui-components";
|
||||
import PluginAvatar from "./PluginAvatar";
|
||||
|
||||
type Props = {
|
||||
plugin: Plugin
|
||||
};
|
||||
|
||||
class PluginEntry extends React.Component<Props> {
|
||||
createAvatar = (plugin: Plugin) => {
|
||||
return <PluginAvatar plugin={plugin} />;
|
||||
};
|
||||
|
||||
createFooterLeft = (plugin: Plugin) => {
|
||||
return <small className="level-item">{plugin.author}</small>;
|
||||
};
|
||||
|
||||
createFooterRight = (plugin: Plugin) => {
|
||||
return <p className="level-item">{plugin.version}</p>;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { plugin } = this.props;
|
||||
const avatar = this.createAvatar(plugin);
|
||||
const footerLeft = this.createFooterLeft(plugin);
|
||||
const footerRight = this.createFooterRight(plugin);
|
||||
|
||||
// TODO: Add link to plugin page below
|
||||
return (
|
||||
<CardColumn
|
||||
link="#"
|
||||
avatar={avatar}
|
||||
title={plugin.name}
|
||||
description={plugin.description}
|
||||
footerLeft={footerLeft}
|
||||
footerRight={footerRight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginEntry;
|
||||
21
scm-ui/src/admin/plugins/components/PluginGroupEntry.js
Normal file
21
scm-ui/src/admin/plugins/components/PluginGroupEntry.js
Normal file
@@ -0,0 +1,21 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { CardColumnGroup } from "@scm-manager/ui-components";
|
||||
import type { PluginGroup } from "@scm-manager/ui-types";
|
||||
import PluginEntry from "./PluginEntry";
|
||||
|
||||
type Props = {
|
||||
group: PluginGroup
|
||||
};
|
||||
|
||||
class PluginGroupEntry extends React.Component<Props> {
|
||||
render() {
|
||||
const { group } = this.props;
|
||||
const entries = group.plugins.map((plugin, index) => {
|
||||
return <PluginEntry plugin={plugin} key={index} />;
|
||||
});
|
||||
return <CardColumnGroup name={group.name} elements={entries} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginGroupEntry;
|
||||
26
scm-ui/src/admin/plugins/components/PluginsList.js
Normal file
26
scm-ui/src/admin/plugins/components/PluginsList.js
Normal file
@@ -0,0 +1,26 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Plugin } from "@scm-manager/ui-types";
|
||||
import PluginGroupEntry from "../components/PluginGroupEntry";
|
||||
import groupByCategory from "./groupByCategory";
|
||||
|
||||
type Props = {
|
||||
plugins: Plugin[]
|
||||
};
|
||||
|
||||
class PluginList extends React.Component<Props> {
|
||||
render() {
|
||||
const { plugins } = this.props;
|
||||
|
||||
const groups = groupByCategory(plugins);
|
||||
return (
|
||||
<div className="content is-plugin-page">
|
||||
{groups.map(group => {
|
||||
return <PluginGroupEntry group={group} key={group.name} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginList;
|
||||
39
scm-ui/src/admin/plugins/components/groupByCategory.js
Normal file
39
scm-ui/src/admin/plugins/components/groupByCategory.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import type { Plugin, PluginGroup } from "@scm-manager/ui-types";
|
||||
|
||||
export default function groupByCategory(
|
||||
plugins: Plugin[]
|
||||
): PluginGroup[] {
|
||||
let groups = {};
|
||||
for (let plugin of plugins) {
|
||||
const groupName = plugin.type;
|
||||
|
||||
let group = groups[groupName];
|
||||
if (!group) {
|
||||
group = {
|
||||
name: groupName,
|
||||
plugins: []
|
||||
};
|
||||
groups[groupName] = group;
|
||||
}
|
||||
group.plugins.push(plugin);
|
||||
}
|
||||
|
||||
let groupArray = [];
|
||||
for (let groupName in groups) {
|
||||
const group = groups[groupName];
|
||||
group.plugins.sort(sortByName);
|
||||
groupArray.push(groups[groupName]);
|
||||
}
|
||||
groupArray.sort(sortByName);
|
||||
return groupArray;
|
||||
}
|
||||
|
||||
function sortByName(a, b) {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
} else if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
108
scm-ui/src/admin/plugins/containers/PluginsOverview.js
Normal file
108
scm-ui/src/admin/plugins/containers/PluginsOverview.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { compose } from "redux";
|
||||
import type { PluginCollection } from "@scm-manager/ui-types";
|
||||
import {
|
||||
Loading,
|
||||
Title,
|
||||
Subtitle,
|
||||
Notification,
|
||||
ErrorNotification
|
||||
} from "@scm-manager/ui-components";
|
||||
import {
|
||||
fetchPluginsByLink,
|
||||
getFetchPluginsFailure,
|
||||
getPluginCollection,
|
||||
isFetchPluginsPending
|
||||
} from "../modules/plugins";
|
||||
import PluginsList from "../components/PluginsList";
|
||||
import { getPluginsLink } from "../../../modules/indexResource";
|
||||
|
||||
type Props = {
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
collection: PluginCollection,
|
||||
baseUrl: string,
|
||||
installed: boolean,
|
||||
pluginsLink: string,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
|
||||
// dispatched functions
|
||||
fetchPluginsByLink: (link: string) => void
|
||||
};
|
||||
|
||||
class PluginsOverview extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { fetchPluginsByLink, pluginsLink } = this.props;
|
||||
fetchPluginsByLink(pluginsLink);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, error, collection, installed, t } = this.props;
|
||||
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
}
|
||||
|
||||
if (!collection || loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title title={t("plugins.title")} />
|
||||
<Subtitle
|
||||
subtitle={
|
||||
installed
|
||||
? t("plugins.installedSubtitle")
|
||||
: t("plugins.availableSubtitle")
|
||||
}
|
||||
/>
|
||||
{this.renderPluginsList()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderPluginsList() {
|
||||
const { collection, t } = this.props;
|
||||
|
||||
if (collection._embedded && collection._embedded.plugins.length > 0) {
|
||||
return <PluginsList plugins={collection._embedded.plugins} />;
|
||||
}
|
||||
return <Notification type="info">{t("plugins.noPlugins")}</Notification>;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const collection = getPluginCollection(state);
|
||||
const loading = isFetchPluginsPending(state);
|
||||
const error = getFetchPluginsFailure(state);
|
||||
const pluginsLink = getPluginsLink(state);
|
||||
|
||||
return {
|
||||
collection,
|
||||
loading,
|
||||
error,
|
||||
pluginsLink
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchPluginsByLink: (link: string) => {
|
||||
dispatch(fetchPluginsByLink(link));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default compose(
|
||||
translate("admin"),
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)
|
||||
)(PluginsOverview);
|
||||
191
scm-ui/src/admin/plugins/modules/plugins.js
Normal file
191
scm-ui/src/admin/plugins/modules/plugins.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// @flow
|
||||
import * as types from "../../../modules/types";
|
||||
import { isPending } from "../../../modules/pending";
|
||||
import { getFailure } from "../../../modules/failure";
|
||||
import type { Action, Plugin, PluginCollection } from "@scm-manager/ui-types";
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
|
||||
export const FETCH_PLUGINS = "scm/plugins/FETCH_PLUGINS";
|
||||
export const FETCH_PLUGINS_PENDING = `${FETCH_PLUGINS}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_PLUGINS_SUCCESS = `${FETCH_PLUGINS}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_PLUGINS_FAILURE = `${FETCH_PLUGINS}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_PLUGIN = "scm/plugins/FETCH_PLUGIN";
|
||||
export const FETCH_PLUGIN_PENDING = `${FETCH_PLUGIN}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_PLUGIN_SUCCESS = `${FETCH_PLUGIN}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_PLUGIN_FAILURE = `${FETCH_PLUGIN}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
// fetch plugins
|
||||
export function fetchPluginsByLink(link: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchPluginsPending());
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => response.json())
|
||||
.then(plugins => {
|
||||
dispatch(fetchPluginsSuccess(plugins));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchPluginsFailure(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPluginsPending(): Action {
|
||||
return {
|
||||
type: FETCH_PLUGINS_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPluginsSuccess(plugins: PluginCollection): Action {
|
||||
return {
|
||||
type: FETCH_PLUGINS_SUCCESS,
|
||||
payload: plugins
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPluginsFailure(err: Error): Action {
|
||||
return {
|
||||
type: FETCH_PLUGINS_FAILURE,
|
||||
payload: err
|
||||
};
|
||||
}
|
||||
|
||||
// fetch plugin
|
||||
export function fetchPluginByLink(plugin: Plugin) {
|
||||
return fetchPlugin(plugin._links.self.href, plugin.name);
|
||||
}
|
||||
|
||||
export function fetchPluginByName(link: string, name: string) {
|
||||
const pluginUrl = link.endsWith("/") ? link : link + "/";
|
||||
return fetchPlugin(pluginUrl + name, name);
|
||||
}
|
||||
|
||||
function fetchPlugin(link: string, name: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchPluginPending(name));
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => response.json())
|
||||
.then(plugin => {
|
||||
dispatch(fetchPluginSuccess(plugin));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchPluginFailure(name, err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPluginPending(name: string): Action {
|
||||
return {
|
||||
type: FETCH_PLUGIN_PENDING,
|
||||
payload: {
|
||||
name
|
||||
},
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPluginSuccess(plugin: Plugin): Action {
|
||||
return {
|
||||
type: FETCH_PLUGIN_SUCCESS,
|
||||
payload: plugin,
|
||||
itemId: plugin.name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPluginFailure(name: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_PLUGIN_FAILURE,
|
||||
payload: {
|
||||
name,
|
||||
error
|
||||
},
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
// reducer
|
||||
function normalizeByName(pluginCollection: PluginCollection) {
|
||||
const names = [];
|
||||
const byNames = {};
|
||||
for (const plugin of pluginCollection._embedded.plugins) {
|
||||
names.push(plugin.name);
|
||||
byNames[plugin.name] = plugin;
|
||||
}
|
||||
return {
|
||||
list: {
|
||||
...pluginCollection,
|
||||
_embedded: {
|
||||
plugins: names
|
||||
}
|
||||
},
|
||||
byNames: byNames
|
||||
};
|
||||
}
|
||||
|
||||
const reducerByNames = (state: Object, plugin: Plugin) => {
|
||||
return {
|
||||
...state,
|
||||
byNames: {
|
||||
...state.byNames,
|
||||
[plugin.name]: plugin
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
if (!action.payload) {
|
||||
return state;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case FETCH_PLUGINS_SUCCESS:
|
||||
return normalizeByName(action.payload);
|
||||
case FETCH_PLUGIN_SUCCESS:
|
||||
return reducerByNames(state, action.payload);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// selectors
|
||||
export function getPluginCollection(state: Object) {
|
||||
if (state.plugins && state.plugins.list && state.plugins.byNames) {
|
||||
const plugins = [];
|
||||
for (let pluginName of state.plugins.list._embedded.plugins) {
|
||||
plugins.push(state.plugins.byNames[pluginName]);
|
||||
}
|
||||
return {
|
||||
...state.plugins.list,
|
||||
_embedded: {
|
||||
plugins
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchPluginsPending(state: Object) {
|
||||
return isPending(state, FETCH_PLUGINS);
|
||||
}
|
||||
|
||||
export function getFetchPluginsFailure(state: Object) {
|
||||
return getFailure(state, FETCH_PLUGINS);
|
||||
}
|
||||
|
||||
export function getPlugin(state: Object, name: string) {
|
||||
if (state.plugins && state.plugins.byNames) {
|
||||
return state.plugins.byNames[name];
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchPluginPending(state: Object, name: string) {
|
||||
return isPending(state, FETCH_PLUGIN, name);
|
||||
}
|
||||
|
||||
export function getFetchPluginFailure(state: Object, name: string) {
|
||||
return getFailure(state, FETCH_PLUGIN, name);
|
||||
}
|
||||
353
scm-ui/src/admin/plugins/modules/plugins.test.js
Normal file
353
scm-ui/src/admin/plugins/modules/plugins.test.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// @flow
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
import reducer, {
|
||||
FETCH_PLUGINS,
|
||||
FETCH_PLUGINS_PENDING,
|
||||
FETCH_PLUGINS_SUCCESS,
|
||||
FETCH_PLUGINS_FAILURE,
|
||||
FETCH_PLUGIN,
|
||||
FETCH_PLUGIN_PENDING,
|
||||
FETCH_PLUGIN_SUCCESS,
|
||||
FETCH_PLUGIN_FAILURE,
|
||||
fetchPluginsByLink,
|
||||
fetchPluginsSuccess,
|
||||
getPluginCollection,
|
||||
isFetchPluginsPending,
|
||||
getFetchPluginsFailure,
|
||||
fetchPluginByLink,
|
||||
fetchPluginByName,
|
||||
fetchPluginSuccess,
|
||||
getPlugin,
|
||||
isFetchPluginPending,
|
||||
getFetchPluginFailure
|
||||
} from "./plugins";
|
||||
import type { Plugin, PluginCollection } from "@scm-manager/ui-types";
|
||||
|
||||
const groupManagerPlugin: Plugin = {
|
||||
name: "scm-groupmanager-plugin",
|
||||
bundles: ["/scm/groupmanager-plugin.bundle.js"],
|
||||
type: "Administration",
|
||||
version: "2.0.0-SNAPSHOT",
|
||||
author: "Sebastian Sdorra",
|
||||
description: "Notify a remote webserver whenever a plugin is pushed to.",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scriptPlugin: Plugin = {
|
||||
name: "scm-script-plugin",
|
||||
bundles: ["/scm/script-plugin.bundle.js"],
|
||||
type: "Miscellaneous",
|
||||
version: "2.0.0-SNAPSHOT",
|
||||
author: "Sebastian Sdorra",
|
||||
description: "Script support for scm-manager.",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/ui/plugins/scm-script-plugin"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const branchwpPlugin: Plugin = {
|
||||
name: "scm-branchwp-plugin",
|
||||
bundles: ["/scm/branchwp-plugin.bundle.js"],
|
||||
type: "Miscellaneous",
|
||||
version: "2.0.0-SNAPSHOT",
|
||||
author: "Sebastian Sdorra",
|
||||
description: "This plugin adds branch write protection for plugins.",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/ui/plugins/scm-branchwp-plugin"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pluginCollectionWithNames: PluginCollection = {
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/ui/plugins"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
plugins: [groupManagerPlugin.name, scriptPlugin.name, branchwpPlugin.name]
|
||||
}
|
||||
};
|
||||
|
||||
const pluginCollection: PluginCollection = {
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/api/v2/ui/plugins"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
plugins: [groupManagerPlugin, scriptPlugin, branchwpPlugin]
|
||||
}
|
||||
};
|
||||
|
||||
describe("plugins fetch", () => {
|
||||
const URL = "ui/plugins";
|
||||
const PLUGINS_URL = "/api/v2/ui/plugins";
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch plugins from link", () => {
|
||||
fetchMock.getOnce(PLUGINS_URL, pluginCollection);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_PLUGINS_PENDING },
|
||||
{
|
||||
type: FETCH_PLUGINS_SUCCESS,
|
||||
payload: pluginCollection
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchPluginsByLink(URL)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_PLUGINS_FAILURE if request fails", () => {
|
||||
fetchMock.getOnce(PLUGINS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchPluginsByLink(URL)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_PLUGINS_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_PLUGINS_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully fetch scm-groupmanager-plugin by name", () => {
|
||||
fetchMock.getOnce(
|
||||
PLUGINS_URL + "/scm-groupmanager-plugin",
|
||||
groupManagerPlugin
|
||||
);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: FETCH_PLUGIN_PENDING,
|
||||
payload: {
|
||||
name: "scm-groupmanager-plugin"
|
||||
},
|
||||
itemId: "scm-groupmanager-plugin"
|
||||
},
|
||||
{
|
||||
type: FETCH_PLUGIN_SUCCESS,
|
||||
payload: groupManagerPlugin,
|
||||
itemId: "scm-groupmanager-plugin"
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store
|
||||
.dispatch(fetchPluginByName(URL, "scm-groupmanager-plugin"))
|
||||
.then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_PLUGIN_FAILURE, if the request for scm-groupmanager-plugin by name fails", () => {
|
||||
fetchMock.getOnce(PLUGINS_URL + "/scm-groupmanager-plugin", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store
|
||||
.dispatch(fetchPluginByName(URL, "scm-groupmanager-plugin"))
|
||||
.then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_PLUGIN_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_PLUGIN_FAILURE);
|
||||
expect(actions[1].payload.name).toBe("scm-groupmanager-plugin");
|
||||
expect(actions[1].payload.error).toBeDefined();
|
||||
expect(actions[1].itemId).toBe("scm-groupmanager-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully fetch scm-groupmanager-plugin", () => {
|
||||
fetchMock.getOnce(
|
||||
"http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin",
|
||||
groupManagerPlugin
|
||||
);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: FETCH_PLUGIN_PENDING,
|
||||
payload: {
|
||||
name: "scm-groupmanager-plugin"
|
||||
},
|
||||
itemId: "scm-groupmanager-plugin"
|
||||
},
|
||||
{
|
||||
type: FETCH_PLUGIN_SUCCESS,
|
||||
payload: groupManagerPlugin,
|
||||
itemId: "scm-groupmanager-plugin"
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchPluginByLink(groupManagerPlugin)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_PLUGIN_FAILURE, it the request for scm-groupmanager-plugin fails", () => {
|
||||
fetchMock.getOnce(
|
||||
"http://localhost:8081/api/v2/ui/plugins/scm-groupmanager-plugin",
|
||||
{
|
||||
status: 500
|
||||
}
|
||||
);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchPluginByLink(groupManagerPlugin)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_PLUGIN_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_PLUGIN_FAILURE);
|
||||
expect(actions[1].payload.name).toBe("scm-groupmanager-plugin");
|
||||
expect(actions[1].payload.error).toBeDefined();
|
||||
expect(actions[1].itemId).toBe("scm-groupmanager-plugin");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins 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 plugins by it's type and name on FETCH_PLUGINS_SUCCESS", () => {
|
||||
const newState = reducer({}, fetchPluginsSuccess(pluginCollection));
|
||||
expect(newState.list._embedded.plugins).toEqual([
|
||||
"scm-groupmanager-plugin",
|
||||
"scm-script-plugin",
|
||||
"scm-branchwp-plugin"
|
||||
]);
|
||||
expect(newState.byNames["scm-groupmanager-plugin"]).toBe(
|
||||
groupManagerPlugin
|
||||
);
|
||||
expect(newState.byNames["scm-script-plugin"]).toBe(scriptPlugin);
|
||||
expect(newState.byNames["scm-branchwp-plugin"]).toBe(branchwpPlugin);
|
||||
});
|
||||
|
||||
it("should store the plugin at byNames", () => {
|
||||
const newState = reducer({}, fetchPluginSuccess(groupManagerPlugin));
|
||||
expect(newState.byNames["scm-groupmanager-plugin"]).toBe(
|
||||
groupManagerPlugin
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins selectors", () => {
|
||||
const error = new Error("something went wrong");
|
||||
|
||||
it("should return the plugins collection", () => {
|
||||
const state = {
|
||||
plugins: {
|
||||
list: pluginCollectionWithNames,
|
||||
byNames: {
|
||||
"scm-groupmanager-plugin": groupManagerPlugin,
|
||||
"scm-script-plugin": scriptPlugin,
|
||||
"scm-branchwp-plugin": branchwpPlugin
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collection = getPluginCollection(state);
|
||||
expect(collection).toEqual(pluginCollection);
|
||||
});
|
||||
|
||||
it("should return true, when fetch plugins is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_PLUGINS]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchPluginsPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch plugins is not pending", () => {
|
||||
expect(isFetchPluginsPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch plugins did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_PLUGINS]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchPluginsFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch plugins did not fail", () => {
|
||||
expect(getFetchPluginsFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return the plugin collection", () => {
|
||||
const state = {
|
||||
plugins: {
|
||||
byNames: {
|
||||
"scm-groupmanager-plugin": groupManagerPlugin
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const plugin = getPlugin(state, "scm-groupmanager-plugin");
|
||||
expect(plugin).toEqual(groupManagerPlugin);
|
||||
});
|
||||
|
||||
it("should return true, when fetch plugin is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_PLUGIN + "/scm-groupmanager-plugin"]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchPluginPending(state, "scm-groupmanager-plugin")).toEqual(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false, when fetch plugin is not pending", () => {
|
||||
expect(isFetchPluginPending({}, "scm-groupmanager-plugin")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch plugin did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_PLUGIN + "/scm-groupmanager-plugin"]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchPluginFailure(state, "scm-groupmanager-plugin")).toEqual(
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch plugin did not fail", () => {
|
||||
expect(getFetchPluginFailure({}, "scm-groupmanager-plugin")).toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import config from "./admin/modules/config";
|
||||
import roles from "./admin/roles/modules/roles";
|
||||
import namespaceStrategies from "./admin/modules/namespaceStrategies";
|
||||
import indexResources from "./modules/indexResource";
|
||||
import plugins from "./admin/plugins/modules/plugins";
|
||||
|
||||
import type { BrowserHistory } from "history/createBrowserHistory";
|
||||
import branches from "./repos/branches/modules/branches";
|
||||
@@ -42,7 +43,8 @@ function createReduxStore(history: BrowserHistory) {
|
||||
config,
|
||||
roles,
|
||||
sources,
|
||||
namespaceStrategies
|
||||
namespaceStrategies,
|
||||
plugins
|
||||
});
|
||||
|
||||
return createStore(
|
||||
|
||||
@@ -116,6 +116,10 @@ export function getUiPluginsLink(state: Object) {
|
||||
return getLink(state, "uiPlugins");
|
||||
}
|
||||
|
||||
export function getPluginsLink(state: Object) {
|
||||
return getLink(state, "plugins");
|
||||
}
|
||||
|
||||
export function getMeLink(state: Object) {
|
||||
return getLink(state, "me");
|
||||
}
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import injectSheet from "react-jss";
|
||||
import type { Repository } from "@scm-manager/ui-types";
|
||||
import { DateFromNow } from "@scm-manager/ui-components";
|
||||
import { CardColumn, DateFromNow } from "@scm-manager/ui-components";
|
||||
import RepositoryEntryLink from "./RepositoryEntryLink";
|
||||
import classNames from "classnames";
|
||||
import RepositoryAvatar from "./RepositoryAvatar";
|
||||
|
||||
const styles = {
|
||||
inner: {
|
||||
position: "relative",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1
|
||||
},
|
||||
innerLink: {
|
||||
pointerEvents: "all"
|
||||
},
|
||||
centerImage: {
|
||||
marginTop: "0.8em",
|
||||
marginLeft: "1em !important"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
fullColumnWidth?: boolean,
|
||||
// context props
|
||||
classes: any
|
||||
repository: Repository
|
||||
};
|
||||
|
||||
class RepositoryEntry extends React.Component<Props> {
|
||||
@@ -83,53 +62,41 @@ class RepositoryEntry extends React.Component<Props> {
|
||||
return null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { repository, classes, fullColumnWidth } = this.props;
|
||||
const repositoryLink = this.createLink(repository);
|
||||
const halfColumn = fullColumnWidth ? "is-full" : "is-half";
|
||||
const overlayLinkClass = fullColumnWidth
|
||||
? "overlay-full-column"
|
||||
: "overlay-half-column";
|
||||
createFooterLeft = (repository: Repository, repositoryLink: string) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"box",
|
||||
"box-link-shadow",
|
||||
"column",
|
||||
"is-clipped",
|
||||
halfColumn
|
||||
)}
|
||||
>
|
||||
<Link className={classNames(overlayLinkClass)} to={repositoryLink} />
|
||||
<article className={classNames("media", classes.inner)}>
|
||||
<figure className={classNames(classes.centerImage, "media-left")}>
|
||||
<RepositoryAvatar repository={repository} />
|
||||
</figure>
|
||||
<div className={classNames("media-content", "text-box")}>
|
||||
<div className="content">
|
||||
<p className="is-marginless">
|
||||
<strong>{repository.name}</strong>
|
||||
</p>
|
||||
<p className={"shorten-text"}>{repository.description}</p>
|
||||
</div>
|
||||
<nav className="level is-mobile">
|
||||
<div className="level-left">
|
||||
<>
|
||||
{this.renderBranchesLink(repository, repositoryLink)}
|
||||
{this.renderChangesetsLink(repository, repositoryLink)}
|
||||
{this.renderSourcesLink(repository, repositoryLink)}
|
||||
{this.renderModifyLink(repository, repositoryLink)}
|
||||
</div>
|
||||
<div className="level-right is-hidden-mobile">
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
createFooterRight = (repository: Repository) => {
|
||||
return (
|
||||
<small className="level-item">
|
||||
<DateFromNow date={repository.creationDate} />
|
||||
</small>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { repository } = this.props;
|
||||
const repositoryLink = this.createLink(repository);
|
||||
const footerLeft = this.createFooterLeft(repository, repositoryLink);
|
||||
const footerRight = this.createFooterRight(repository);
|
||||
return (
|
||||
<CardColumn
|
||||
avatar={<RepositoryAvatar repository={repository} />}
|
||||
title={repository.name}
|
||||
description={repository.description}
|
||||
link={repositoryLink}
|
||||
footerLeft={footerLeft}
|
||||
footerRight={footerRight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(RepositoryEntry);
|
||||
export default RepositoryEntry;
|
||||
|
||||
@@ -1,92 +1,21 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { RepositoryGroup, Repository } from "@scm-manager/ui-types";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
import { CardColumnGroup } from "@scm-manager/ui-components";
|
||||
import type { RepositoryGroup } from "@scm-manager/ui-types";
|
||||
import RepositoryEntry from "./RepositoryEntry";
|
||||
|
||||
const styles = {
|
||||
pointer: {
|
||||
cursor: "pointer",
|
||||
fontSize: "1.5rem"
|
||||
},
|
||||
repoGroup: {
|
||||
marginBottom: "1em"
|
||||
},
|
||||
wrapper: {
|
||||
padding: "0 0.75rem"
|
||||
},
|
||||
clearfix: {
|
||||
clear: "both"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
group: RepositoryGroup,
|
||||
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean
|
||||
};
|
||||
|
||||
class RepositoryGroupEntry extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false
|
||||
};
|
||||
}
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState(prevState => ({
|
||||
collapsed: !prevState.collapsed
|
||||
}));
|
||||
};
|
||||
|
||||
isLastEntry = (array: Repository[], index: number) => {
|
||||
return index === array.length - 1;
|
||||
};
|
||||
|
||||
isLengthOdd = (array: Repository[]) => {
|
||||
return array.length % 2 !== 0;
|
||||
};
|
||||
|
||||
isFullSize = (array: Repository[], index: number) => {
|
||||
return this.isLastEntry(array, index) && this.isLengthOdd(array);
|
||||
group: RepositoryGroup
|
||||
};
|
||||
|
||||
class RepositoryGroupEntry extends React.Component<Props> {
|
||||
render() {
|
||||
const { group, classes } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
|
||||
let content = null;
|
||||
if (!collapsed) {
|
||||
content = group.repositories.map((repository, index) => {
|
||||
const fullColumnWidth = this.isFullSize(group.repositories, index);
|
||||
return (
|
||||
<RepositoryEntry repository={repository} fullColumnWidth={fullColumnWidth} key={index} />
|
||||
);
|
||||
const { group } = this.props;
|
||||
const entries = group.repositories.map((repository, index) => {
|
||||
return <RepositoryEntry repository={repository} key={index} />;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className={classes.repoGroup}>
|
||||
<h2>
|
||||
<span className={classes.pointer} onClick={this.toggleCollapse}>
|
||||
<i className={classNames("fa", icon)} /> {group.name}
|
||||
</span>
|
||||
</h2>
|
||||
<hr />
|
||||
<div className={classNames("columns", "is-multiline", classes.wrapper)}>
|
||||
{content}
|
||||
</div>
|
||||
<div className={classes.clearfix} />
|
||||
</div>
|
||||
);
|
||||
return <CardColumnGroup name={group.name} elements={entries} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(RepositoryGroupEntry);
|
||||
export default RepositoryGroupEntry;
|
||||
|
||||
@@ -159,27 +159,31 @@ ul.is-separated {
|
||||
|
||||
// multiline Columns
|
||||
.columns.is-multiline {
|
||||
.column {
|
||||
height: 120px;
|
||||
|
||||
.overlay-column {
|
||||
position: absolute;
|
||||
height: calc(120px - 1.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.column.is-half {
|
||||
width: calc(50% - 0.75rem);
|
||||
max-height: 120px;
|
||||
|
||||
&:nth-child(odd) {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.overlay-half-column {
|
||||
position: absolute;
|
||||
height: calc(120px - 1.5rem);
|
||||
.overlay-column {
|
||||
width: calc(50% - 3rem);
|
||||
}
|
||||
}
|
||||
.column.is-full {
|
||||
.overlay-full-column {
|
||||
position: absolute;
|
||||
height: calc(120px - 0.5rem);
|
||||
|
||||
.column.is-full .overlay-column {
|
||||
width: calc(100% - 1.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.column.is-half {
|
||||
width: 100%;
|
||||
@@ -188,9 +192,25 @@ ul.is-separated {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.overlay-half-column {
|
||||
position: absolute;
|
||||
height: calc(120px - 0.5rem);
|
||||
.overlay-column {
|
||||
width: calc(100% - 1.5rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.content.is-plugin-page {
|
||||
.columns.is-multiline {
|
||||
.column.is-half .overlay-column {
|
||||
width: calc(37.5% - 1.5rem);
|
||||
}
|
||||
|
||||
.column.is-full .overlay-column {
|
||||
width: calc(75% - 1.5rem);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.column.is-half .overlay-column,
|
||||
.column.is-full .overlay-column {
|
||||
width: calc(100% - 1.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.apache.shiro.SecurityUtils;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.config.ConfigurationPermissions;
|
||||
import sonia.scm.group.GroupPermissions;
|
||||
import sonia.scm.plugin.PluginPermissions;
|
||||
import sonia.scm.repository.RepositoryRolePermissions;
|
||||
import sonia.scm.security.PermissionPermissions;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
@@ -34,11 +35,15 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
||||
List<Link> autoCompleteLinks = Lists.newArrayList();
|
||||
builder.self(resourceLinks.index().self());
|
||||
builder.single(link("uiPlugins", resourceLinks.uiPluginCollection().self()));
|
||||
|
||||
if (SecurityUtils.getSubject().isAuthenticated()) {
|
||||
builder.single(
|
||||
link("me", resourceLinks.me().self()),
|
||||
link("logout", resourceLinks.authentication().logout())
|
||||
);
|
||||
if (PluginPermissions.read().isPermitted()) {
|
||||
builder.single(link("plugins", resourceLinks.pluginCollection().self()));
|
||||
}
|
||||
if (UserPermissions.list().isPermitted()) {
|
||||
builder.single(link("users", resourceLinks.userCollection().self()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Links;
|
||||
import lombok.Getter;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class PluginDto extends HalRepresentation {
|
||||
|
||||
private String name;
|
||||
private String type;
|
||||
private String version;
|
||||
private String author;
|
||||
private String description;
|
||||
|
||||
public PluginDto(Links links) {
|
||||
add(links);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import de.otto.edison.hal.Embedded;
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Links;
|
||||
import sonia.scm.plugin.PluginWrapper;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static de.otto.edison.hal.Embedded.embeddedBuilder;
|
||||
import static de.otto.edison.hal.Links.linkingTo;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
public class PluginDtoCollectionMapper {
|
||||
|
||||
private final ResourceLinks resourceLinks;
|
||||
private final PluginDtoMapper mapper;
|
||||
|
||||
@Inject
|
||||
public PluginDtoCollectionMapper(ResourceLinks resourceLinks, PluginDtoMapper mapper) {
|
||||
this.resourceLinks = resourceLinks;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public HalRepresentation map(Collection<PluginWrapper> plugins) {
|
||||
List<PluginDto> dtos = plugins.stream().map(mapper::map).collect(toList());
|
||||
return new HalRepresentation(createLinks(), embedDtos(dtos));
|
||||
}
|
||||
|
||||
private Links createLinks() {
|
||||
String baseUrl = resourceLinks.pluginCollection().self();
|
||||
|
||||
Links.Builder linksBuilder = linkingTo()
|
||||
.with(Links.linkingTo().self(baseUrl).build());
|
||||
return linksBuilder.build();
|
||||
}
|
||||
|
||||
private Embedded embedDtos(List<PluginDto> dtos) {
|
||||
return embeddedBuilder()
|
||||
.with("plugins", dtos)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.Links;
|
||||
import sonia.scm.plugin.PluginWrapper;
|
||||
import javax.inject.Inject;
|
||||
|
||||
import static de.otto.edison.hal.Links.linkingTo;
|
||||
|
||||
public class PluginDtoMapper {
|
||||
|
||||
private final ResourceLinks resourceLinks;
|
||||
|
||||
@Inject
|
||||
public PluginDtoMapper(ResourceLinks resourceLinks) {
|
||||
this.resourceLinks = resourceLinks;
|
||||
}
|
||||
|
||||
public PluginDto map(PluginWrapper plugin) {
|
||||
Links.Builder linksBuilder = linkingTo()
|
||||
.self(resourceLinks.plugin()
|
||||
.self(plugin.getPlugin().getInformation().getId(false)));
|
||||
|
||||
PluginDto pluginDto = new PluginDto(linksBuilder.build());
|
||||
pluginDto.setName(plugin.getPlugin().getInformation().getName());
|
||||
pluginDto.setType(plugin.getPlugin().getInformation().getCategory() != null ? plugin.getPlugin().getInformation().getCategory() : "Miscellaneous");
|
||||
pluginDto.setVersion(plugin.getPlugin().getInformation().getVersion());
|
||||
pluginDto.setAuthor(plugin.getPlugin().getInformation().getAuthor());
|
||||
pluginDto.setDescription(plugin.getPlugin().getInformation().getDescription());
|
||||
|
||||
return pluginDto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
|
||||
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
|
||||
import com.webcohesion.enunciate.metadata.rs.TypeHint;
|
||||
import sonia.scm.plugin.Plugin;
|
||||
import sonia.scm.plugin.PluginLoader;
|
||||
import sonia.scm.plugin.PluginPermissions;
|
||||
import sonia.scm.plugin.PluginWrapper;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static sonia.scm.ContextEntry.ContextBuilder.entity;
|
||||
import static sonia.scm.NotFoundException.notFound;
|
||||
|
||||
public class PluginResource {
|
||||
|
||||
private final PluginLoader pluginLoader;
|
||||
private final PluginDtoCollectionMapper collectionMapper;
|
||||
private final PluginDtoMapper mapper;
|
||||
|
||||
@Inject
|
||||
public PluginResource(PluginLoader pluginLoader, PluginDtoCollectionMapper collectionMapper, PluginDtoMapper mapper) {
|
||||
this.pluginLoader = pluginLoader;
|
||||
this.collectionMapper = collectionMapper;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of installed plugins.
|
||||
*
|
||||
* @return collection of installed plugins.
|
||||
*/
|
||||
@GET
|
||||
@Path("")
|
||||
@StatusCodes({
|
||||
@ResponseCode(code = 200, condition = "success"),
|
||||
@ResponseCode(code = 500, condition = "internal server error")
|
||||
})
|
||||
@TypeHint(CollectionDto.class)
|
||||
@Produces(VndMediaType.PLUGIN_COLLECTION)
|
||||
public Response getInstalledPlugins() {
|
||||
PluginPermissions.read().check();
|
||||
List<PluginWrapper> plugins = new ArrayList<>(pluginLoader.getInstalledPlugins());
|
||||
return Response.ok(collectionMapper.map(plugins)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the installed plugin with the given id.
|
||||
*
|
||||
* @param id id of plugin
|
||||
*
|
||||
* @return installed plugin with specified id
|
||||
*/
|
||||
@GET
|
||||
@Path("{id}")
|
||||
@StatusCodes({
|
||||
@ResponseCode(code = 200, condition = "success"),
|
||||
@ResponseCode(code = 404, condition = "not found"),
|
||||
@ResponseCode(code = 500, condition = "internal server error")
|
||||
})
|
||||
@TypeHint(PluginDto.class)
|
||||
@Produces(VndMediaType.PLUGIN)
|
||||
public Response getInstalledPlugin(@PathParam("id") String id) {
|
||||
PluginPermissions.read().check();
|
||||
Optional<PluginDto> pluginDto = pluginLoader.getInstalledPlugins()
|
||||
.stream()
|
||||
.filter(plugin -> id.equals(plugin.getPlugin().getInformation().getId(false)))
|
||||
.map(mapper::map)
|
||||
.findFirst();
|
||||
if (pluginDto.isPresent()) {
|
||||
return Response.ok(pluginDto.get()).build();
|
||||
} else {
|
||||
throw notFound(entity(Plugin.class, id));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Provider;
|
||||
import javax.ws.rs.Path;
|
||||
|
||||
@Path("v2/")
|
||||
public class PluginRootResource {
|
||||
|
||||
private Provider<PluginResource> pluginResourceProvider;
|
||||
|
||||
@Inject
|
||||
public PluginRootResource(Provider<PluginResource> pluginResourceProvider) {
|
||||
this.pluginResourceProvider = pluginResourceProvider;
|
||||
}
|
||||
|
||||
@Path("plugins")
|
||||
public PluginResource plugins() {
|
||||
return pluginResourceProvider.get();
|
||||
}
|
||||
}
|
||||
@@ -651,6 +651,38 @@ class ResourceLinks {
|
||||
}
|
||||
}
|
||||
|
||||
public PluginLinks plugin() {
|
||||
return new PluginLinks(scmPathInfoStore.get());
|
||||
}
|
||||
|
||||
static class PluginLinks {
|
||||
private final LinkBuilder pluginLinkBuilder;
|
||||
|
||||
PluginLinks(ScmPathInfo pathInfo) {
|
||||
pluginLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginResource.class);
|
||||
}
|
||||
|
||||
String self(String id) {
|
||||
return pluginLinkBuilder.method("plugins").parameters().method("getInstalledPlugin").parameters(id).href();
|
||||
}
|
||||
}
|
||||
|
||||
public PluginCollectionLinks pluginCollection() {
|
||||
return new PluginCollectionLinks(scmPathInfoStore.get());
|
||||
}
|
||||
|
||||
static class PluginCollectionLinks {
|
||||
private final LinkBuilder pluginCollectionLinkBuilder;
|
||||
|
||||
PluginCollectionLinks(ScmPathInfo pathInfo) {
|
||||
pluginCollectionLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginResource.class);
|
||||
}
|
||||
|
||||
String self() {
|
||||
return pluginCollectionLinkBuilder.method("plugins").parameters().method("getInstalledPlugins").parameters().href();
|
||||
}
|
||||
}
|
||||
|
||||
public AuthenticationLinks authentication() {
|
||||
return new AuthenticationLinks(scmPathInfoStore.get());
|
||||
}
|
||||
|
||||
@@ -67,7 +67,13 @@
|
||||
<value>configuration:read,write:*</value>
|
||||
</permission>
|
||||
<permission>
|
||||
<value>repositoryRole:write</value>
|
||||
<value>repositoryRole:read,write</value>
|
||||
</permission>
|
||||
<permission>
|
||||
<value>plugin:read</value>
|
||||
</permission>
|
||||
<permission>
|
||||
<value>plugin:read,write</value>
|
||||
</permission>
|
||||
|
||||
</permissions>
|
||||
|
||||
|
||||
@@ -66,6 +66,16 @@
|
||||
"description": "Kann benutzerdefinierte Rollen und deren Berechtigungen erstellen, ändern und löschen"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"read": {
|
||||
"displayName": "Alle Plugins lesen",
|
||||
"description": "Darf alle installierten und verfügbaren Plugins lesen"
|
||||
},
|
||||
"read,write": {
|
||||
"displayName": "Alle Plugins lesen und verwalten",
|
||||
"description": "Darf alle installierten und verfügbaren Plugins lesen und verwalten"
|
||||
}
|
||||
},
|
||||
"unknown": "Unbekannte Berechtigung"
|
||||
},
|
||||
"verbs": {
|
||||
|
||||
@@ -66,6 +66,16 @@
|
||||
"description": "May create, modify and delete custom repository roles and their permissions"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"read": {
|
||||
"displayName": "Read all plugins",
|
||||
"description": "May see all installed and available plugins"
|
||||
},
|
||||
"read,write": {
|
||||
"displayName": "Read and manage all plugins",
|
||||
"description": "May see and manage all installed and available plugins"
|
||||
}
|
||||
},
|
||||
"unknown": "Unknown permission"
|
||||
},
|
||||
"verbs": {
|
||||
|
||||
Reference in New Issue
Block a user