mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 00:15:44 +01:00
Merge with upstream
This commit is contained in:
@@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Align actionbar item horizontal and enforce correct margin between them ([#1358](https://github.com/scm-manager/scm-manager/pull/1358))
|
- Align actionbar item horizontal and enforce correct margin between them ([#1358](https://github.com/scm-manager/scm-manager/pull/1358))
|
||||||
|
|
||||||
## [2.6.1] - 2020-09-30
|
## [2.6.1] - 2020-09-30
|
||||||
|
### Added
|
||||||
|
- Users can create API keys with limited permissions ([#1359](https://github.com/scm-manager/scm-manager/pull/1359))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Not found error when using browse command in empty hg repository ([#1355](https://github.com/scm-manager/scm-manager/pull/1355))
|
- Not found error when using browse command in empty hg repository ([#1355](https://github.com/scm-manager/scm-manager/pull/1355))
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335))
|
- Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335))
|
||||||
- Show update info on admin information page ([#1342](https://github.com/scm-manager/scm-manager/pull/1342))
|
- Show update info on admin information page ([#1342](https://github.com/scm-manager/scm-manager/pull/1342))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Rework modal to use react portal ([#1349](https://github.com/scm-manager/scm-manager/pull/1349))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328))
|
- Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328))
|
||||||
- Missing BranchCreatedEvent for mercurial ([#1334](https://github.com/scm-manager/scm-manager/pull/1334))
|
- Missing BranchCreatedEvent for mercurial ([#1334](https://github.com/scm-manager/scm-manager/pull/1334))
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
- /user/user/
|
- /user/user/
|
||||||
- /user/group/
|
- /user/group/
|
||||||
- /user/admin/
|
- /user/admin/
|
||||||
|
- /user/profile/
|
||||||
|
|||||||
BIN
docs/de/user/profile/assets/api-key-created.png
Normal file
BIN
docs/de/user/profile/assets/api-key-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/de/user/profile/assets/api-key-overview.png
Normal file
BIN
docs/de/user/profile/assets/api-key-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
50
docs/de/user/profile/index.md
Normal file
50
docs/de/user/profile/index.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Profil
|
||||||
|
partiallyActive: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Über den Link zum Profil im Footer können Einstellungen zum eigenen Konto vorgenommen werden.
|
||||||
|
|
||||||
|
## Passwort ändern
|
||||||
|
|
||||||
|
Hier kann das Passwort für das Konto geändert werden, wenn es sich um ein lokales Konto handelt (wenn die Anmeldung
|
||||||
|
also nicht über ein Fremdsystem erfolgt). Um die Änderung zu autorisieren, muss zunächst das aktuelle Passwort
|
||||||
|
eingegeben werden. Danach muss das neue Passwort zweimal eingegeben werden.
|
||||||
|
|
||||||
|
## Öffentliche Schlüssel
|
||||||
|
|
||||||
|
Zum Prüfen von Signaturen für z. B. Commits können hier die entsprechenden öffentlichen Schlüssel hinterlegt werden.
|
||||||
|
Zudem können hier die vom SCM-Manager erstellten Signaturschlüssel heruntergeladen werden.
|
||||||
|
|
||||||
|
## API Schlüssel
|
||||||
|
|
||||||
|
Zur Nutzung in anderen Systemen wie z. B. CI Systemen können sogenannte API Schlüssel erstellt werden. Sie können für
|
||||||
|
den Zugriff auf Repositories über die REST API sowie über SCM-Clients genutzt werden. Dazu wird ein Anzeigename sowie
|
||||||
|
eine [Rolle](../admin/roles/) ausgewählt. Der Anzeigename ist ausschließlich zur Verwaltung gedacht und hat keine
|
||||||
|
weitere technische Bewandnis. Mithilfe der Rolle können die Berechtigungen eingeschränkt werden, die bei einer Anmeldung
|
||||||
|
zur Verfügung stehen.
|
||||||
|
|
||||||
|
Hat z. B. ein Konto schreibende Rechte für ein Repository und wird ein API-Schlüssel mit der Rolle "READ" erzeugt, so
|
||||||
|
kann über diesen Schlüssel nur lesend auf das Repository zugegriffen werden. Eine Ausweitung der Rechte hingegen ist
|
||||||
|
selbstverständlich nicht möglich. Es kann also mithilfe eines API-Schlüssels mit der Rolle "WRITE" nicht schreibend auf
|
||||||
|
ein Repository zugegriffen werden, für das bei dem Konto nur ein lesender Zugriff gestattet ist.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nach der Erstellung eines Schlüssels, wird dieser **einmalig** angezeigt. Nachdem dieses Fenster
|
||||||
|
geschlossen wurde, kann der Schlüssel nicht mehr abgerufen und nicht wiederhergestellt werden.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Beispiel REST API
|
||||||
|
|
||||||
|
Um einen Schlüssel mit der REST API zu nutzen, muss der Schlüssel als Cookie mit dem Namen „X-Bearer-Token“
|
||||||
|
übergeben werden. Für die Nutzung mit curl sieht ein Aufruf z. B. wie folgt aus:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -v localhost:8081/scm/api/v2/repositories/ -H "Cookie: X-Bearer-Token=eyJhcGlLZXlJZCI...RTRHeCJ9"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zugriff mit SCM-Client
|
||||||
|
|
||||||
|
Für einen Zugriff mit einem SCM-Client (z. B. `git`, `hg` oder `svn`) muss der Schlüssel als Passwort übergeben werden.
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
- /user/user/
|
- /user/user/
|
||||||
- /user/group/
|
- /user/group/
|
||||||
- /user/admin/
|
- /user/admin/
|
||||||
|
- /user/profile/
|
||||||
|
|
||||||
- section: Administration
|
- section: Administration
|
||||||
entries:
|
entries:
|
||||||
|
|||||||
BIN
docs/en/user/profile/assets/api-key-created.png
Normal file
BIN
docs/en/user/profile/assets/api-key-created.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/en/user/profile/assets/api-key-overview.png
Normal file
BIN
docs/en/user/profile/assets/api-key-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
49
docs/en/user/profile/index.md
Normal file
49
docs/en/user/profile/index.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: Profile
|
||||||
|
partiallyActive: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Settings for the active user account can be managed with the link "Profile" in the footer.
|
||||||
|
|
||||||
|
## Change password
|
||||||
|
|
||||||
|
Here the password for the current account can be changed when it is a local account (when the login is not managed by an
|
||||||
|
external system). To authorize the change, the current password has to be put first. Then the new password has to be
|
||||||
|
entered twice.
|
||||||
|
|
||||||
|
## Öffentliche Schlüssel
|
||||||
|
|
||||||
|
To check signatures for example for commits, public keys can be stored here. Additionally the keys created by
|
||||||
|
SCM-Manager can be accessed here, too.
|
||||||
|
|
||||||
|
## API keys
|
||||||
|
|
||||||
|
To access SCM-Manager from other systems like for example CI servers, API keys can be created. They can be used to call
|
||||||
|
the REST APi and for the access with SCM clients. To create a key you have to specify a display name and a
|
||||||
|
[role](../admin/roles/). The display name is solely to keep track of your keys. The role limits the permissions granted
|
||||||
|
when the SCM-Manager is accessed with such a key.
|
||||||
|
|
||||||
|
If, for exapmple, an account has write access for a repository and an API key with the role "READ" is created for this
|
||||||
|
account, this repository can only be accessed read only using this key. Of course it is not possible to extend
|
||||||
|
permissions. So you cannot create an API key with the role "WRITE" to get write access to a repository, where the
|
||||||
|
original account has only read access for.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
After the creation of a key, it will be displayed **once only**. After the window has been closed, the key cannot be
|
||||||
|
retrieved or reconstructed again.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Example for the REST API
|
||||||
|
|
||||||
|
To use an API key for the REST API, the key has to sent as a cookie with the name “X-Bearer-Token”. Using curl, this
|
||||||
|
can be done like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -v localhost:8081/scm/api/v2/repositories/ -H "Cookie: X-Bearer-Token=eyJhcGlLZXlJZCI...RTRHeCJ9"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access with an SCM-Client
|
||||||
|
|
||||||
|
For access with an SCM client like `git`, `hg`, or `svn` the key simply has to be passed as a password.
|
||||||
2
pom.xml
2
pom.xml
@@ -905,7 +905,7 @@
|
|||||||
<!-- test libraries -->
|
<!-- test libraries -->
|
||||||
<mockito.version>3.5.10</mockito.version>
|
<mockito.version>3.5.10</mockito.version>
|
||||||
<hamcrest.version>2.1</hamcrest.version>
|
<hamcrest.version>2.1</hamcrest.version>
|
||||||
<junit.version>5.6.2</junit.version>
|
<junit.version>5.7.0</junit.version>
|
||||||
|
|
||||||
<!-- logging libraries -->
|
<!-- logging libraries -->
|
||||||
<slf4j.version>1.7.30</slf4j.version>
|
<slf4j.version>1.7.30</slf4j.version>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.user;
|
package sonia.scm.user;
|
||||||
|
|
||||||
//~--- non-JDK imports --------------------------------------------------------
|
//~--- non-JDK imports --------------------------------------------------------
|
||||||
@@ -50,7 +50,7 @@ import java.security.Principal;
|
|||||||
@StaticPermissions(
|
@StaticPermissions(
|
||||||
value = "user",
|
value = "user",
|
||||||
globalPermissions = {"create", "list", "autocomplete"},
|
globalPermissions = {"create", "list", "autocomplete"},
|
||||||
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"},
|
permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"},
|
||||||
custom = true, customGlobal = true
|
custom = true, customGlobal = true
|
||||||
)
|
)
|
||||||
@XmlRootElement(name = "users")
|
@XmlRootElement(name = "users")
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ public class VndMediaType {
|
|||||||
public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX;
|
public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX;
|
||||||
public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX;
|
public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX;
|
||||||
|
|
||||||
|
public static final String API_KEY = PREFIX + "apiKey" + SUFFIX;
|
||||||
|
public static final String API_KEY_COLLECTION = PREFIX + "apiKeyCollection" + SUFFIX;
|
||||||
|
|
||||||
private VndMediaType() {
|
private VndMediaType() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java
Normal file
115
scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.it;
|
||||||
|
|
||||||
|
import io.restassured.RestAssured;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import sonia.scm.it.utils.RepositoryUtil;
|
||||||
|
import sonia.scm.it.utils.RestUtil;
|
||||||
|
import sonia.scm.it.utils.TestData;
|
||||||
|
import sonia.scm.repository.client.api.RepositoryClient;
|
||||||
|
import sonia.scm.repository.client.api.RepositoryClientException;
|
||||||
|
import sonia.scm.web.VndMediaType;
|
||||||
|
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static sonia.scm.it.utils.RepositoryUtil.addAndCommitRandomFile;
|
||||||
|
import static sonia.scm.it.utils.RestUtil.given;
|
||||||
|
import static sonia.scm.it.utils.TestData.WRITE;
|
||||||
|
|
||||||
|
public class ApiKeyITCase {
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void prepareEnvironment() {
|
||||||
|
TestData.createDefault();
|
||||||
|
TestData.createNotAdminUser("user", "user");
|
||||||
|
TestData.createUserPermission("user", WRITE, "git");
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void cleanup() {
|
||||||
|
TestData.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldCloneWithRestrictedApiKey() throws IOException {
|
||||||
|
String passphrase = registerApiKey();
|
||||||
|
|
||||||
|
RepositoryClient client = RepositoryUtil.createRepositoryClient("git", temporaryFolder.newFolder(), "user", passphrase);
|
||||||
|
|
||||||
|
assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldFailToCommit() throws IOException {
|
||||||
|
String passphrase = registerApiKey();
|
||||||
|
|
||||||
|
RepositoryClient client = RepositoryUtil.createRepositoryClient("git", temporaryFolder.newFolder(), "user", passphrase);
|
||||||
|
|
||||||
|
assertThrows(RepositoryClientException.class, () -> addAndCommitRandomFile(client, "user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public String registerApiKey() {
|
||||||
|
String apiKeysUrl = given(VndMediaType.ME, "user", "user")
|
||||||
|
.when()
|
||||||
|
.get(RestUtil.createResourceUrl("me/"))
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.body().jsonPath().getString("_links.apiKeys.href");
|
||||||
|
String createUrl = given(VndMediaType.API_KEY_COLLECTION)
|
||||||
|
.when()
|
||||||
|
.get(apiKeysUrl)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.body().jsonPath().getString("_links.create.href");
|
||||||
|
String passphrase = new String(RestAssured.given()
|
||||||
|
.contentType(VndMediaType.API_KEY)
|
||||||
|
.accept(MediaType.TEXT_PLAIN)
|
||||||
|
.auth().preemptive().basic("user", "user")
|
||||||
|
.when()
|
||||||
|
.body("{\"displayName\":\"integration test\",\"permissionRole\":\"READ\"}")
|
||||||
|
.post(createUrl)
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.body()
|
||||||
|
.asByteArray());
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47242,575 +47242,30 @@ exports[`Storyshots MarkdownView Xml Code Block 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Storyshots Modal|ConfirmAlert Default 1`] = `
|
exports[`Storyshots Modal|ConfirmAlert Default 1`] = `null`;
|
||||||
<div
|
|
||||||
className="modal is-active"
|
exports[`Storyshots Modal|ConfirmAlert WithButton 1`] = `
|
||||||
>
|
Array [
|
||||||
<div
|
<button
|
||||||
className="modal-background"
|
onClick={[Function]}
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="modal-card"
|
|
||||||
>
|
>
|
||||||
<header
|
Open ConfirmAlert
|
||||||
className="modal-card-head has-background-light"
|
</button>,
|
||||||
>
|
<div
|
||||||
<p
|
id="modalRoot"
|
||||||
className="modal-card-title is-marginless"
|
/>,
|
||||||
>
|
]
|
||||||
Are you sure about that?
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
aria-label="close"
|
|
||||||
className="delete"
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<section
|
|
||||||
className="modal-card-body"
|
|
||||||
>
|
|
||||||
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob.
|
|
||||||
</section>
|
|
||||||
<footer
|
|
||||||
className="modal-card-foot"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="field is-grouped"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
className="button is-info is-outlined"
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
className="button is-info"
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Storyshots Modal|Modal Closeable 1`] = `
|
exports[`Storyshots Modal|Modal Closeable 1`] = `null`;
|
||||||
<div
|
|
||||||
className="modal is-active"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-background"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="modal-card"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className="modal-card-head has-background-light"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="modal-card-title is-marginless"
|
|
||||||
>
|
|
||||||
Hitchhiker Modal
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
aria-label="close"
|
|
||||||
className="delete"
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<section
|
|
||||||
className="modal-card-body"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Storyshots Modal|Modal Default 1`] = `
|
exports[`Storyshots Modal|Modal Default 1`] = `null`;
|
||||||
<div
|
|
||||||
className="modal is-active"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-background"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="modal-card"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className="modal-card-head has-background-light"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="modal-card-title is-marginless"
|
|
||||||
>
|
|
||||||
Hitchhiker Modal
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
aria-label="close"
|
|
||||||
className="delete"
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<section
|
|
||||||
className="modal-card-body"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Storyshots Modal|Modal Long content 1`] = `
|
exports[`Storyshots Modal|Modal Long content 1`] = `null`;
|
||||||
<div
|
|
||||||
className="modal is-active"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-background"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="modal-card"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className="modal-card-head has-background-light"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="modal-card-title is-marginless"
|
|
||||||
>
|
|
||||||
Hitchhiker Modal
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
aria-label="close"
|
|
||||||
className="delete"
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<section
|
|
||||||
className="modal-card-body"
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
className="title"
|
|
||||||
>
|
|
||||||
Marvin
|
|
||||||
</h1>
|
|
||||||
<h2
|
|
||||||
className="subtitle"
|
|
||||||
>
|
|
||||||
The Paranoid Android
|
|
||||||
</h2>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="notification is-info"
|
|
||||||
>
|
|
||||||
|
|
||||||
The following content comes from the awesome
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://hitchhikers.fandom.com/wiki/Main_Page"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Hitchhikers Wiki
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="has-text-centered"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Marvin"
|
|
||||||
src="https://vignette.wikia.nocookie.net/hitchhikers/images/a/a4/Marvin.jpg/revision/latest/scale-to-width-down/150?cb=20100530114055"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<p
|
|
||||||
className="content"
|
|
||||||
>
|
|
||||||
Marvin, more fully known as Marvin the Paranoid Android, is an incredibly brilliant but overwhelmingly depressed robot manufactured by the Sirius Cybernetics Corporation and unwilling servant to the crew of the Heart of Gold.
|
|
||||||
</p>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="content"
|
|
||||||
>
|
|
||||||
<h4>
|
|
||||||
Physical Appearance
|
|
||||||
</h4>
|
|
||||||
<p>
|
|
||||||
In the novels, Marvin is described thusly: "...though it was beautifully constructed and polished it looked somehow as if the various parts of its more or less humanoid body didn't quite fit properly. In fact, they fit perfectly well, but something in its bearing suggested that they might have fitted better."
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
On the radio show, there's no physical description of Marvin, though his voice is digitally altered to sound more robotic, and any scene that focuses on him is accompanied by sounds of mechanical clanking and hissing.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
In the TV series, Marvin is built in the style of a 1950's robot similar to Robbie the Robot from Forbidden Planet or Twiki from Buck Rogers. His body is blocky and angular, with a pair of clamp-claw hands, shuffling feet and a squarish head with a dour face.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
In the movie, Marvin is a short, stout robot built of smooth, white plastic. His arms are much longer than his legs, and his head is a massive sphere with only a pair of triangle eyes for a face. His large head and simian-like proportions give Marvin a perpetual slouch, adding to his melancholy personality. At the start of the film his eyes glow, but at the end he is shot but unharmed, leaving a hole in his head and dimming his eyes. This is probably the most depressing and unacceptable manifestation of Marvin ever conceived, and thus paradoxically the most accurate.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="content"
|
|
||||||
>
|
|
||||||
<h4>
|
|
||||||
Personality
|
|
||||||
</h4>
|
|
||||||
<p>
|
|
||||||
Marvin the robot has a prototype version of the Genuine People Personality (GPP) software from SCC, allowing him sentience and the ability to feel emotions and develop a personality. He's also incredibly smart, having a "brain the size of a planet" capable of computing extremely complex mathematics, as well as solving difficult problems and operating high-tech devices.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
However, despite being so smart, Marvin is typically made to perform menial tasks and labour such as escorting people, opening doors, picking up pieces of paper, and other tasks well beneath his skills. Even extremely hard tasks, such as computing for the vast Krikkit robot army, are trivial for Marvin. All this leaves him extremely bored, frustrated, and overwhelmingly depressed. Because of this, all modern GPP-capable machines, such as Eddie the computer and the Heart of Gold's automatic doors, are programmed to be extremely cheerful and happy, much to Marvin's disgust.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Marvin hates everyone and everything he comes into contact with, having no respect for anybody and will criticise and insult others at any opportunity, or otherwise rant and complain for hours on end about his own problems, such as the terrible pain he suffers in all the diodes down his left side. His contempt for everyone is often justified, as almost every person he comes across, even those who consider him a friend, (such as Arthur and Trillian, who treat him more kindly than Ford and Zaphod) treat Marvin as an expendable servant, even sending him to his death more than once (such as when Zaphod ordered Marvin to fight the gigantic, heavy-duty Frogstar Scout Robot Class D so he could escape). Being a robot, he still does what he's told (he won't enjoy it, nor will he let you forget it, but he'll do it anyway), though he'd much rather sulk in a corner by himself.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Several times in the series Marvin ends up alone and isolated for extremely long periods of time, sometimes spanning millions of years, either by sheer bad luck (such as the explosion that propelled everyone but Marvin to Milliways in the far-off future) or because his unpleasantly depressing personality drives them away or, in more than one case, makes them commit suicide. In his spare time (which he has a lot of), Marvin will attempt to occupy himself by composing songs and writing poetry. Of course, none of them are particularly cheerful, or even that good.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Storyshots Modal|Modal With form elements 1`] = `
|
exports[`Storyshots Modal|Modal With form elements 1`] = `null`;
|
||||||
<div
|
|
||||||
className="modal is-active"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-background"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="modal-card"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className="modal-card-head has-background-light"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="modal-card-title is-marginless"
|
|
||||||
>
|
|
||||||
Hitchhiker Modal
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
aria-label="close"
|
|
||||||
className="delete"
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<section
|
|
||||||
className="modal-card-body"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="Modalstories__RadioList-sc-2lb0wg-1 bDdhEj"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="Radio__StyledRadio-ays4vp-0 cKfIQb radio"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
checked={true}
|
|
||||||
onChange={[Function]}
|
|
||||||
type="radio"
|
|
||||||
/>
|
|
||||||
|
|
||||||
One
|
|
||||||
<span
|
|
||||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
|
|
||||||
data-tooltip="The first one"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-question-circle has-text-blue-light"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
className="Radio__StyledRadio-ays4vp-0 cKfIQb radio"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
checked={false}
|
|
||||||
onChange={[Function]}
|
|
||||||
type="radio"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Two
|
|
||||||
<span
|
|
||||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
|
|
||||||
data-tooltip="The second one"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-question-circle has-text-blue-light"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<p>
|
|
||||||
Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob.
|
|
||||||
</p>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="field"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="label"
|
|
||||||
>
|
|
||||||
Text
|
|
||||||
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
className="textarea"
|
|
||||||
disabled={false}
|
|
||||||
onChange={[Function]}
|
|
||||||
onKeyDown={[Function]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="field is-grouped"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="button is-default"
|
|
||||||
onClick={[Function]}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
One
|
|
||||||
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="button is-default"
|
|
||||||
onClick={[Function]}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Two
|
|
||||||
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Storyshots Modal|Modal With long tooltips 1`] = `
|
exports[`Storyshots Modal|Modal With long tooltips 1`] = `null`;
|
||||||
<div
|
|
||||||
className="modal is-active"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-background"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="modal-card"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className="modal-card-head has-background-light"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="modal-card-title is-marginless"
|
|
||||||
>
|
|
||||||
Hitchhiker Modal
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
aria-label="close"
|
|
||||||
className="delete"
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<section
|
|
||||||
className="modal-card-body"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="notification is-info"
|
|
||||||
>
|
|
||||||
|
|
||||||
This story exists because we had a problem, that long tooltips causes a horizontal scrollbar on the modal.
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<p>
|
|
||||||
The following elements will have a verly long help text, which has triggered the scrollbar in the past.
|
|
||||||
</p>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="field"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="control"
|
|
||||||
onClick={[Function]}
|
|
||||||
onKeyDown={[Function]}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="checkbox"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="gwt-Anchor"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="is-outlined fa-check-square has-text-link fa"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</span>
|
|
||||||
Checkbox
|
|
||||||
<span
|
|
||||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
|
|
||||||
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob."
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-question-circle has-text-blue-light"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="Radio__StyledRadio-ays4vp-0 cKfIQb radio"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
checked={false}
|
|
||||||
onChange={[Function]}
|
|
||||||
type="radio"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Radio button
|
|
||||||
<span
|
|
||||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
|
|
||||||
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob."
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-question-circle has-text-blue-light"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="field"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="label"
|
|
||||||
>
|
|
||||||
Input
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
|
|
||||||
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob."
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-question-circle has-text-blue-light"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
onChange={[Function]}
|
|
||||||
onKeyPress={[Function]}
|
|
||||||
placeholder=""
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div
|
|
||||||
className="Modalstories__TopAndBottomMargin-sc-2lb0wg-0 cBXOgT"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="field"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="label"
|
|
||||||
>
|
|
||||||
Textarea
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 OUPvb is-inline-block has-tooltip-multiline"
|
|
||||||
data-tooltip="Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows
|
|
||||||
hostess’s really oblong Infinite Improbability thing into the starship against which behavior accordance.with
|
|
||||||
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
|
|
||||||
ordinary mob."
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="fas fa-question-circle has-text-blue-light"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="control"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
className="textarea"
|
|
||||||
disabled={false}
|
|
||||||
onChange={[Function]}
|
|
||||||
onKeyDown={[Function]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<p>
|
|
||||||
If this modal has no horizontal scrollbar the issue is fixed
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Storyshots Navigation|Secondary Active when match 1`] = `
|
exports[`Storyshots Navigation|Secondary Active when match 1`] = `
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ const Footer: FC<Props> = ({ me, version, links }) => {
|
|||||||
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
|
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
|
||||||
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
|
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
|
||||||
{me?._links?.publicKeys && <NavLink to="/me/settings/publicKeys" label={t("profile.publicKeysNavLink")} />}
|
{me?._links?.publicKeys && <NavLink to="/me/settings/publicKeys" label={t("profile.publicKeysNavLink")} />}
|
||||||
|
{me?._links?.apiKeys && <NavLink to="/me/settings/apiKeys" label={t("profile.apiKeysNavLink")} />}
|
||||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||||
</FooterSection>
|
</FooterSection>
|
||||||
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
import { storiesOf } from "@storybook/react";
|
import { storiesOf } from "@storybook/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ConfirmAlert from "./ConfirmAlert";
|
import ConfirmAlert, { confirmAlert } from "./ConfirmAlert";
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
"Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " +
|
"Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " +
|
||||||
@@ -40,11 +40,21 @@ const buttons = [
|
|||||||
onClick: () => null
|
onClick: () => null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Submit",
|
label: "Submit"
|
||||||
onClick: () => {}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
storiesOf("Modal|ConfirmAlert", module)
|
storiesOf("Modal|ConfirmAlert", module)
|
||||||
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
|
||||||
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />);
|
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />)
|
||||||
|
.add("WithButton", () => {
|
||||||
|
const buttonClick = () => {
|
||||||
|
confirmAlert({ message: body, title: "Are you sure about that?", buttons });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={buttonClick}>Open ConfirmAlert</button>
|
||||||
|
<div id="modalRoot" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { FC, useState } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -29,59 +30,71 @@ import classNames from "classnames";
|
|||||||
type Button = {
|
type Button = {
|
||||||
className?: string;
|
className?: string;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void | null;
|
onClick?: () => void | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
buttons: Button[];
|
buttons: Button[];
|
||||||
|
close?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ConfirmAlert extends React.Component<Props> {
|
export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
|
||||||
handleClickButton = (button: Button) => {
|
const [showModal, setShowModal] = useState(true);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
if (typeof close === "function") {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickButton = (button: Button) => {
|
||||||
if (button.onClick) {
|
if (button.onClick) {
|
||||||
button.onClick();
|
button.onClick();
|
||||||
}
|
}
|
||||||
this.close();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
close = () => {
|
const body = <>{message}</>;
|
||||||
const container = document.getElementById("modalRoot");
|
|
||||||
if (container) {
|
|
||||||
ReactDOM.unmountComponentAtNode(container);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
const footer = (
|
||||||
const { title, message, buttons } = this.props;
|
<div className="field is-grouped">
|
||||||
|
{buttons.map((button, i) => (
|
||||||
|
<p className="control">
|
||||||
|
<a
|
||||||
|
className={classNames("button", "is-info", button.className)}
|
||||||
|
key={i}
|
||||||
|
onClick={() => handleClickButton(button)}
|
||||||
|
>
|
||||||
|
{button.label}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const body = <>{message}</>;
|
return (
|
||||||
|
(showModal && <Modal title={title} closeFunction={onClose} body={body} active={true} footer={footer} />) || null
|
||||||
const footer = (
|
);
|
||||||
<div className="field is-grouped">
|
};
|
||||||
{buttons.map((button, i) => (
|
|
||||||
<p className="control">
|
|
||||||
<a
|
|
||||||
className={classNames("button", "is-info", button.className)}
|
|
||||||
key={i}
|
|
||||||
onClick={() => this.handleClickButton(button)}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return <Modal title={title} closeFunction={() => this.close()} body={body} active={true} footer={footer} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Please use {@link ConfirmAlert} directly.
|
||||||
|
*/
|
||||||
export function confirmAlert(properties: Props) {
|
export function confirmAlert(properties: Props) {
|
||||||
const root = document.getElementById("modalRoot");
|
const root = document.getElementById("modalRoot");
|
||||||
if (root) {
|
if (root) {
|
||||||
ReactDOM.render(<ConfirmAlert {...properties} />, root);
|
const close = () => {
|
||||||
|
const container = document.getElementById("modalRoot");
|
||||||
|
if (container) {
|
||||||
|
ReactDOM.unmountComponentAtNode(container);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const props = { ...properties, close };
|
||||||
|
ReactDOM.render(<ConfirmAlert {...props} />, root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {FC} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import usePortalRootElement from "../usePortalRootElement";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -31,38 +34,38 @@ type Props = {
|
|||||||
footer?: any;
|
footer?: any;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
headColor: string;
|
headColor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Modal extends React.Component<Props> {
|
export const Modal: FC<Props> = ({ title, closeFunction, body, footer, active, className, headColor = "light" }) => {
|
||||||
static defaultProps = {
|
const portalRootElement = usePortalRootElement("modalsRoot");
|
||||||
headColor: "light"
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
if (!portalRootElement) {
|
||||||
const { title, closeFunction, body, footer, active, className, headColor } = this.props;
|
return null;
|
||||||
|
|
||||||
const isActive = active ? "is-active" : null;
|
|
||||||
|
|
||||||
let showFooter = null;
|
|
||||||
if (footer) {
|
|
||||||
showFooter = <footer className="modal-card-foot">{footer}</footer>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames("modal", className, isActive)}>
|
|
||||||
<div className="modal-background" />
|
|
||||||
<div className="modal-card">
|
|
||||||
<header className={classNames("modal-card-head", `has-background-${headColor}`)}>
|
|
||||||
<p className="modal-card-title is-marginless">{title}</p>
|
|
||||||
<button className="delete" aria-label="close" onClick={closeFunction} />
|
|
||||||
</header>
|
|
||||||
<section className="modal-card-body">{body}</section>
|
|
||||||
{showFooter}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const isActive = active ? "is-active" : null;
|
||||||
|
|
||||||
|
let showFooter = null;
|
||||||
|
if (footer) {
|
||||||
|
showFooter = <footer className="modal-card-foot">{footer}</footer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalElement = (
|
||||||
|
<div className={classNames("modal", className, isActive)}>
|
||||||
|
<div className="modal-background" />
|
||||||
|
<div className="modal-card">
|
||||||
|
<header className={classNames("modal-card-head", `has-background-${headColor}`)}>
|
||||||
|
<p className="modal-card-title is-marginless">{title}</p>
|
||||||
|
<button className="delete" aria-label="close" onClick={closeFunction} />
|
||||||
|
</header>
|
||||||
|
<section className="modal-card-body">{body}</section>
|
||||||
|
{showFooter}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(modalElement, portalRootElement);
|
||||||
|
};
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|||||||
@@ -30,5 +30,10 @@ jest.mock("react-i18next", () => ({
|
|||||||
t: (key: string) => key
|
t: (key: string) => key
|
||||||
};
|
};
|
||||||
return Component;
|
return Component;
|
||||||
|
},
|
||||||
|
useTranslation: (ns: string) => {
|
||||||
|
return [
|
||||||
|
(key: string) => key
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"informationNavLink": "Information",
|
"informationNavLink": "Information",
|
||||||
"changePasswordNavLink": "Passwort ändern",
|
"changePasswordNavLink": "Passwort ändern",
|
||||||
"publicKeysNavLink": "Öffentliche Schlüssel",
|
"publicKeysNavLink": "Öffentliche Schlüssel",
|
||||||
|
"apiKeysNavLink": "API Schlüssel",
|
||||||
"settingsNavLink": "Einstellungen",
|
"settingsNavLink": "Einstellungen",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"displayName": "Anzeigename",
|
"displayName": "Anzeigename",
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
"generalNavLink": "Generell",
|
"generalNavLink": "Generell",
|
||||||
"setPasswordNavLink": "Passwort",
|
"setPasswordNavLink": "Passwort",
|
||||||
"setPermissionsNavLink": "Berechtigungen",
|
"setPermissionsNavLink": "Berechtigungen",
|
||||||
"setPublicKeyNavLink": "Öffentliche Schlüssel"
|
"setPublicKeyNavLink": "Öffentliche Schlüssel",
|
||||||
|
"setApiKeyNavLink": "API Schlüssel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createUser": {
|
"createUser": {
|
||||||
@@ -70,5 +71,27 @@
|
|||||||
"addKey": "Schlüssel hinzufügen",
|
"addKey": "Schlüssel hinzufügen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"download": "Herunterladen"
|
"download": "Herunterladen"
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"noStoredKeys": "Es wurden keine Schlüssel gefunden.",
|
||||||
|
"displayName": "Anzeigename",
|
||||||
|
"permissionRole": {
|
||||||
|
"label": "Berechtigte Rolle",
|
||||||
|
"help": "Mit der Rolle können Sie die Berechtigung für diesen Schlüssel einschränken"
|
||||||
|
},
|
||||||
|
"created": "Eingetragen an",
|
||||||
|
"addKey": "Schlüssel hinzufügen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"text1": "Erstelle und verwalte Personal Access Token um auf die REST API zuzugreifen oder diese als Passwort für SCM-Clients zu nutzen. Die Rechte der Token sind auf Repositories und die gewählte Rolle beschränkt.",
|
||||||
|
"manageRoles": "Sie können die Rollenberechtigungen in der Administration unter „Berechtigungsrollen“ einsehen und neue Rollen anlegen.",
|
||||||
|
"text2": "Um den Token in REST-Abfragen zu nutzen, übergeben Sie diesen als Cookie mit dem Namen „X-Bearer-Token“. Sie können den Token auch anstelle Ihres Passworts nutzen, um sich mit SCM-Clients anzumelden.",
|
||||||
|
"modal": {
|
||||||
|
"title": "Schlüssel erzeugt",
|
||||||
|
"text1": "Ihr neuer API-Schlüssel ist bereit. Sie können diesen als Token für Zugriffe auf die REST-Schnittstelle nutzen oder anstelle Ihres Passworts zum Login mit SCM-Clients nutzen.",
|
||||||
|
"text2": "Sichern Sie Ihren API-Schlüssel jetzt! Er wird hier einmalig angezeigt und kann später nicht mehr wiederbeschafft werden.",
|
||||||
|
"clipboard": "In die Zwischenablage kopieren",
|
||||||
|
"close": "Schließen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,10 @@
|
|||||||
"profile": {
|
"profile": {
|
||||||
"navigationLabel": "Profile",
|
"navigationLabel": "Profile",
|
||||||
"informationNavLink": "Information",
|
"informationNavLink": "Information",
|
||||||
"changePasswordNavLink": "Change password",
|
"changePasswordNavLink": "Change Password",
|
||||||
"settingsNavLink": "Settings",
|
"settingsNavLink": "Settings",
|
||||||
"publicKeysNavLink": "Public Keys",
|
"publicKeysNavLink": "Public Keys",
|
||||||
|
"apiKeysNavLink": "API Keys",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
"generalNavLink": "General",
|
"generalNavLink": "General",
|
||||||
"setPasswordNavLink": "Password",
|
"setPasswordNavLink": "Password",
|
||||||
"setPermissionsNavLink": "Permissions",
|
"setPermissionsNavLink": "Permissions",
|
||||||
"setPublicKeyNavLink": "Public Keys"
|
"setPublicKeyNavLink": "Public Keys",
|
||||||
|
"setApiKeyNavLink": "API Keys"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"createUser": {
|
"createUser": {
|
||||||
@@ -70,5 +71,27 @@
|
|||||||
"addKey": "Add key",
|
"addKey": "Add key",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"download": "Download"
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"noStoredKeys": "No keys found.",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"permissionRole": {
|
||||||
|
"label": "Permitted Role",
|
||||||
|
"help": "The api key will be restricted to permissions of this role"
|
||||||
|
},
|
||||||
|
"created": "Created on",
|
||||||
|
"addKey": "Add key",
|
||||||
|
"delete": "Delete",
|
||||||
|
"download": "Download",
|
||||||
|
"text1": "Create and manage personal access tokens to access the REST API or use as a password for SCM clients. The privileges of these tokens are limited to repositories and the selected role.",
|
||||||
|
"manageRoles": "You may view and create roles in the administration view “Permission Roles”.",
|
||||||
|
"text2": "To use the token in a REST request, pass it as a cookie named “X-Bearer-Token”. You may use the token as your password for SCM clients, too.",
|
||||||
|
"modal": {
|
||||||
|
"title": "Key created",
|
||||||
|
"text1": "Your new API key is ready. You can use it as a bearer token for REST calls or as a password for SCM clients.",
|
||||||
|
"text2": "Store your API key in a safe place now! It is only displayed now and cannot be recovered later.",
|
||||||
|
"clipboard": "Copy to clipboard",
|
||||||
|
"close": "Close"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,80 +21,78 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { compose } from "redux";
|
import { useHistory } from "react-router-dom";
|
||||||
import { withRouter } from "react-router-dom";
|
import { useTranslation } from "react-i18next";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
|
||||||
import { History } from "history";
|
|
||||||
import { RepositoryRole } from "@scm-manager/ui-types";
|
import { RepositoryRole } from "@scm-manager/ui-types";
|
||||||
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
||||||
import { deleteRole, getDeleteRoleFailure, isDeleteRolePending } from "../modules/roles";
|
import { deleteRole, getDeleteRoleFailure, isDeleteRolePending } from "../modules/roles";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error;
|
error: Error;
|
||||||
role: RepositoryRole;
|
role: RepositoryRole;
|
||||||
confirmDialog?: boolean;
|
confirmDialog?: boolean;
|
||||||
deleteRole: (role: RepositoryRole, callback?: () => void) => void;
|
deleteRole: (role: RepositoryRole, callback?: () => void) => void;
|
||||||
|
|
||||||
// context props
|
|
||||||
history: History;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class DeleteRepositoryRole extends React.Component<Props> {
|
const DeleteRepositoryRole: FC<Props> = ({ confirmDialog = true, deleteRole, role, loading, error }: Props) => {
|
||||||
static defaultProps = {
|
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
|
||||||
confirmDialog: true
|
const [t] = useTranslation("admin");
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const roleDeleted = () => {
|
||||||
|
history.push("/admin/roles/");
|
||||||
};
|
};
|
||||||
|
|
||||||
roleDeleted = () => {
|
const deleteRoleCallback = () => {
|
||||||
this.props.history.push("/admin/roles/");
|
deleteRole(role, roleDeleted);
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteRole = () => {
|
const confirmDelete = () => {
|
||||||
this.props.deleteRole(this.props.role, this.roleDeleted);
|
setShowConfirmAlert(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
confirmDelete = () => {
|
const isDeletable = () => {
|
||||||
const { t } = this.props;
|
return role._links.delete;
|
||||||
confirmAlert({
|
|
||||||
title: t("repositoryRole.delete.confirmAlert.title"),
|
|
||||||
message: t("repositoryRole.delete.confirmAlert.message"),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
className: "is-outlined",
|
|
||||||
label: t("repositoryRole.delete.confirmAlert.submit"),
|
|
||||||
onClick: () => this.deleteRole()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("repositoryRole.delete.confirmAlert.cancel"),
|
|
||||||
onClick: () => null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
isDeletable = () => {
|
const action = confirmDialog ? confirmDelete : deleteRoleCallback;
|
||||||
return this.props.role._links.delete;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
if (!isDeletable()) {
|
||||||
const { loading, error, confirmDialog, t } = this.props;
|
return null;
|
||||||
const action = confirmDialog ? this.confirmDelete : this.deleteRole;
|
}
|
||||||
|
|
||||||
if (!this.isDeletable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (showConfirmAlert) {
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfirmAlert
|
||||||
<hr />
|
title={t("repositoryRole.delete.confirmAlert.title")}
|
||||||
<ErrorNotification error={error} />
|
message={t("repositoryRole.delete.confirmAlert.message")}
|
||||||
<Level right={<DeleteButton label={t("repositoryRole.delete.button")} action={action} loading={loading} />} />
|
buttons={[
|
||||||
</>
|
{
|
||||||
|
className: "is-outlined",
|
||||||
|
label: t("repositoryRole.delete.confirmAlert.submit"),
|
||||||
|
onClick: () => deleteRoleCallback()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("repositoryRole.delete.confirmAlert.cancel"),
|
||||||
|
onClick: () => null
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
close={() => setShowConfirmAlert(false)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<ErrorNotification error={error} />
|
||||||
|
<Level right={<DeleteButton label={t("repositoryRole.delete.button")} action={action} loading={loading} />} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||||
const loading = isDeleteRolePending(state, ownProps.role.name);
|
const loading = isDeleteRolePending(state, ownProps.role.name);
|
||||||
@@ -113,8 +111,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default compose(
|
export default connect(mapStateToProps, mapDispatchToProps)(DeleteRepositoryRole);
|
||||||
connect(mapStateToProps, mapDispatchToProps),
|
|
||||||
withRouter,
|
|
||||||
withTranslation("admin")
|
|
||||||
)(DeleteRepositoryRole);
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import ProfileInfo from "./ProfileInfo";
|
|||||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||||
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
|
import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys";
|
||||||
import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
|
import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink";
|
||||||
|
import SetApiKeys from "../users/components/apiKeys/SetApiKeys";
|
||||||
|
import SetApiKeyNavLink from "../users/components/navLinks/SetApiKeysNavLink";
|
||||||
import { urls } from "@scm-manager/ui-components";
|
import { urls } from "@scm-manager/ui-components";
|
||||||
|
|
||||||
type Props = RouteComponentProps &
|
type Props = RouteComponentProps &
|
||||||
@@ -65,6 +67,11 @@ class Profile extends React.Component<Props> {
|
|||||||
return !!me?._links?.publicKeys;
|
return !!me?._links?.publicKeys;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
canManageApiKeys = () => {
|
||||||
|
const { me } = this.props;
|
||||||
|
return !!me?._links?.apiKeys;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const url = urls.matchedUrl(this.props);
|
const url = urls.matchedUrl(this.props);
|
||||||
|
|
||||||
@@ -100,6 +107,9 @@ class Profile extends React.Component<Props> {
|
|||||||
{this.canManagePublicKeys() && (
|
{this.canManagePublicKeys() && (
|
||||||
<Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me} />} />
|
<Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me} />} />
|
||||||
)}
|
)}
|
||||||
|
{this.canManageApiKeys() && (
|
||||||
|
<Route path={`${url}/settings/apiKeys`} render={() => <SetApiKeys user={me} />} />
|
||||||
|
)}
|
||||||
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
|
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
|
||||||
</PrimaryContentColumn>
|
</PrimaryContentColumn>
|
||||||
<SecondaryNavigationColumn>
|
<SecondaryNavigationColumn>
|
||||||
@@ -118,6 +128,7 @@ class Profile extends React.Component<Props> {
|
|||||||
>
|
>
|
||||||
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
|
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
|
||||||
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
|
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
|
||||||
|
<SetApiKeyNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
|
||||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||||
</SubNavigation>
|
</SubNavigation>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,80 +21,78 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { compose } from "redux";
|
import { useHistory } from "react-router-dom";
|
||||||
import { withRouter } from "react-router-dom";
|
import { useTranslation } from "react-i18next";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
|
||||||
import { History } from "history";
|
|
||||||
import { Group } from "@scm-manager/ui-types";
|
import { Group } from "@scm-manager/ui-types";
|
||||||
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
||||||
import { deleteGroup, getDeleteGroupFailure, isDeleteGroupPending } from "../modules/groups";
|
import { deleteGroup, getDeleteGroupFailure, isDeleteGroupPending } from "../modules/groups";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error;
|
error: Error;
|
||||||
group: Group;
|
group: Group;
|
||||||
confirmDialog?: boolean;
|
confirmDialog?: boolean;
|
||||||
deleteGroup: (group: Group, callback?: () => void) => void;
|
deleteGroup: (group: Group, callback?: () => void) => void;
|
||||||
|
|
||||||
// context props
|
|
||||||
history: History;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class DeleteGroup extends React.Component<Props> {
|
export const DeleteGroup: FC<Props> = ({ confirmDialog = true, group, deleteGroup, loading, error }) => {
|
||||||
static defaultProps = {
|
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
|
||||||
confirmDialog: true
|
const [t] = useTranslation("groups");
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const deleteGroupCallback = () => {
|
||||||
|
deleteGroup(group, groupDeleted);
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteGroup = () => {
|
const groupDeleted = () => {
|
||||||
this.props.deleteGroup(this.props.group, this.groupDeleted);
|
history.push("/groups/");
|
||||||
};
|
};
|
||||||
|
|
||||||
groupDeleted = () => {
|
const confirmDelete = () => {
|
||||||
this.props.history.push("/groups/");
|
setShowConfirmAlert(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
confirmDelete = () => {
|
const isDeletable = () => {
|
||||||
const { t } = this.props;
|
return group._links.delete;
|
||||||
confirmAlert({
|
|
||||||
title: t("deleteGroup.confirmAlert.title"),
|
|
||||||
message: t("deleteGroup.confirmAlert.message"),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
className: "is-outlined",
|
|
||||||
label: t("deleteGroup.confirmAlert.submit"),
|
|
||||||
onClick: () => this.deleteGroup()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("deleteGroup.confirmAlert.cancel"),
|
|
||||||
onClick: () => null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
isDeletable = () => {
|
const action = confirmDialog ? confirmDelete : deleteGroupCallback;
|
||||||
return this.props.group._links.delete;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
if (!isDeletable()) {
|
||||||
const { loading, error, confirmDialog, t } = this.props;
|
return null;
|
||||||
const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
|
}
|
||||||
|
|
||||||
if (!this.isDeletable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (showConfirmAlert) {
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfirmAlert
|
||||||
<hr />
|
title={t("deleteGroup.confirmAlert.title")}
|
||||||
<ErrorNotification error={error} />
|
message={t("deleteGroup.confirmAlert.message")}
|
||||||
<Level right={<DeleteButton label={t("deleteGroup.button")} action={action} loading={loading} />} />
|
buttons={[
|
||||||
</>
|
{
|
||||||
|
className: "is-outlined",
|
||||||
|
label: t("deleteGroup.confirmAlert.submit"),
|
||||||
|
onClick: () => deleteGroupCallback()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("deleteGroup.confirmAlert.cancel"),
|
||||||
|
onClick: () => null
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
close={() => setShowConfirmAlert(false)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<ErrorNotification error={error} />
|
||||||
|
<Level right={<DeleteButton label={t("deleteGroup.button")} action={action} loading={loading} />} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||||
const loading = isDeleteGroupPending(state, ownProps.group.name);
|
const loading = isDeleteGroupPending(state, ownProps.group.name);
|
||||||
@@ -113,8 +111,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default compose(
|
export default connect(mapStateToProps, mapDispatchToProps)(DeleteGroup);
|
||||||
connect(mapStateToProps, mapDispatchToProps),
|
|
||||||
withRouter,
|
|
||||||
withTranslation("groups")
|
|
||||||
)(DeleteGroup);
|
|
||||||
|
|||||||
@@ -21,85 +21,86 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { compose } from "redux";
|
import { useHistory } from "react-router-dom";
|
||||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
import { useTranslation } from "react-i18next";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
|
||||||
import { Repository } from "@scm-manager/ui-types";
|
import { Repository } from "@scm-manager/ui-types";
|
||||||
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
||||||
import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos";
|
import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos";
|
||||||
|
|
||||||
type Props = RouteComponentProps &
|
type Props = {
|
||||||
WithTranslation & {
|
loading: boolean;
|
||||||
loading: boolean;
|
error: Error;
|
||||||
error: Error;
|
repository: Repository;
|
||||||
repository: Repository;
|
confirmDialog?: boolean;
|
||||||
confirmDialog?: boolean;
|
deleteRepo: (p1: Repository, p2: () => void) => void;
|
||||||
deleteRepo: (p1: Repository, p2: () => void) => void;
|
};
|
||||||
|
|
||||||
|
const DeleteRepo: FC<Props> = ({ confirmDialog = true, repository, deleteRepo, loading, error }: Props) => {
|
||||||
|
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
|
||||||
|
const [t] = useTranslation("repos");
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const deleted = () => {
|
||||||
|
history.push("/repos/");
|
||||||
};
|
};
|
||||||
|
|
||||||
class DeleteRepo extends React.Component<Props> {
|
const deleteRepoCallback = () => {
|
||||||
static defaultProps = {
|
deleteRepo(repository, deleted);
|
||||||
confirmDialog: true
|
|
||||||
};
|
};
|
||||||
|
|
||||||
deleted = () => {
|
const confirmDelete = () => {
|
||||||
this.props.history.push("/repos/");
|
setShowConfirmAlert(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteRepo = () => {
|
const isDeletable = () => {
|
||||||
this.props.deleteRepo(this.props.repository, this.deleted);
|
return repository._links.delete;
|
||||||
};
|
};
|
||||||
|
|
||||||
confirmDelete = () => {
|
const action = confirmDialog ? confirmDelete : deleteRepoCallback;
|
||||||
const { t } = this.props;
|
|
||||||
confirmAlert({
|
|
||||||
title: t("deleteRepo.confirmAlert.title"),
|
|
||||||
message: t("deleteRepo.confirmAlert.message"),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
className: "is-outlined",
|
|
||||||
label: t("deleteRepo.confirmAlert.submit"),
|
|
||||||
onClick: () => this.deleteRepo()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("deleteRepo.confirmAlert.cancel"),
|
|
||||||
onClick: () => null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
isDeletable = () => {
|
if (!isDeletable()) {
|
||||||
return this.props.repository._links.delete;
|
return null;
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
const { loading, error, confirmDialog, t } = this.props;
|
|
||||||
const action = confirmDialog ? this.confirmDelete : this.deleteRepo;
|
|
||||||
|
|
||||||
if (!this.isDeletable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (showConfirmAlert) {
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfirmAlert
|
||||||
<ErrorNotification error={error} />
|
title={t("deleteRepo.confirmAlert.title")}
|
||||||
<Level
|
message={t("deleteRepo.confirmAlert.message")}
|
||||||
left={
|
buttons={[
|
||||||
<p>
|
{
|
||||||
<strong>{t("deleteRepo.subtitle")}</strong>
|
className: "is-outlined",
|
||||||
<br />
|
label: t("deleteRepo.confirmAlert.submit"),
|
||||||
{t("deleteRepo.description")}
|
onClick: () => deleteRepoCallback()
|
||||||
</p>
|
},
|
||||||
|
{
|
||||||
|
label: t("deleteRepo.confirmAlert.cancel"),
|
||||||
|
onClick: () => null
|
||||||
}
|
}
|
||||||
right={<DeleteButton label={t("deleteRepo.button")} action={action} loading={loading} />}
|
]}
|
||||||
/>
|
close={() => setShowConfirmAlert(false)}
|
||||||
</>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ErrorNotification error={error} />
|
||||||
|
<Level
|
||||||
|
left={
|
||||||
|
<p>
|
||||||
|
<strong>{t("deleteRepo.subtitle")}</strong>
|
||||||
|
<br />
|
||||||
|
{t("deleteRepo.description")}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
right={<DeleteButton label={t("deleteRepo.button")} action={action} loading={loading} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||||
const { namespace, name } = ownProps.repository;
|
const { namespace, name } = ownProps.repository;
|
||||||
@@ -119,4 +120,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default compose(connect(mapStateToProps, mapDispatchToProps), withRouter, withTranslation("repos"))(DeleteRepo);
|
export default connect(mapStateToProps, mapDispatchToProps)(DeleteRepo);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
|||||||
import { Select } from "@scm-manager/ui-components";
|
import { Select } from "@scm-manager/ui-components";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = WithTranslation & {
|
||||||
availableRoles: string[];
|
availableRoles?: string[];
|
||||||
handleRoleChange: (p: string) => void;
|
handleRoleChange: (p: string) => void;
|
||||||
role: string;
|
role: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -21,16 +21,19 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||||
|
// @ts-ignore
|
||||||
|
import React, { FC } from "react";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { mount, shallow } from "@scm-manager/ui-tests/enzyme-router";
|
import { mount, shallow } from "@scm-manager/ui-tests/enzyme-router";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import "@scm-manager/ui-tests/enzyme";
|
import "@scm-manager/ui-tests/enzyme";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import "@scm-manager/ui-tests/i18n";
|
import "@scm-manager/ui-tests/i18n";
|
||||||
import DeletePermissionButton from "./DeletePermissionButton";
|
import DeletePermissionButton from "./DeletePermissionButton";
|
||||||
|
|
||||||
import { confirmAlert } from "@scm-manager/ui-components";
|
|
||||||
|
|
||||||
jest.mock("@scm-manager/ui-components", () => ({
|
jest.mock("@scm-manager/ui-components", () => ({
|
||||||
confirmAlert: jest.fn(),
|
ConfirmAlert: (({ children }) => <div className="modal">{children}</div>) as FC<never>,
|
||||||
DeleteButton: require.requireActual("@scm-manager/ui-components").DeleteButton
|
DeleteButton: require.requireActual("@scm-manager/ui-components").DeleteButton
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -40,6 +43,9 @@ describe("DeletePermissionButton", () => {
|
|||||||
_links: {}
|
_links: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const navLink = shallow(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
|
const navLink = shallow(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
|
||||||
expect(navLink.text()).toBe("");
|
expect(navLink.text()).toBe("");
|
||||||
});
|
});
|
||||||
@@ -53,6 +59,9 @@ describe("DeletePermissionButton", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const deleteIcon = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
|
const deleteIcon = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
|
||||||
expect(deleteIcon.html()).not.toBe("");
|
expect(deleteIcon.html()).not.toBe("");
|
||||||
});
|
});
|
||||||
@@ -66,10 +75,13 @@ describe("DeletePermissionButton", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const button = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
|
const button = mount(<DeletePermissionButton permission={permission} deletePermission={() => {}} />);
|
||||||
button.find(".fa-trash").simulate("click");
|
button.find(".fa-trash").simulate("click");
|
||||||
|
|
||||||
expect(confirmAlert.mock.calls.length).toBe(1);
|
expect(button.find(".modal")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call the delete permission function with delete url", () => {
|
it("should call the delete permission function with delete url", () => {
|
||||||
@@ -82,11 +94,14 @@ describe("DeletePermissionButton", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let calledUrl = null;
|
let calledUrl = null;
|
||||||
|
|
||||||
function capture(permission) {
|
function capture(permission) {
|
||||||
calledUrl = permission._links.delete.href;
|
calledUrl = permission._links.delete.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = mount(
|
const button = mount(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||||
|
// @ts-ignore
|
||||||
<DeletePermissionButton permission={permission} confirmDialog={false} deletePermission={capture} />
|
<DeletePermissionButton permission={permission} confirmDialog={false} deletePermission={capture} />
|
||||||
);
|
);
|
||||||
button.find(".fa-trash").simulate("click");
|
button.find(".fa-trash").simulate("click");
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Permission } from "@scm-manager/ui-types";
|
import { Permission } from "@scm-manager/ui-types";
|
||||||
import { confirmAlert } from "@scm-manager/ui-components";
|
import { ConfirmAlert } from "@scm-manager/ui-components";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
permission: Permission;
|
permission: Permission;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
@@ -35,53 +35,62 @@ type Props = WithTranslation & {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
class DeletePermissionButton extends React.Component<Props> {
|
const DeletePermissionButton: FC<Props> = ({
|
||||||
static defaultProps = {
|
confirmDialog = true,
|
||||||
confirmDialog: true
|
permission,
|
||||||
|
namespace,
|
||||||
|
deletePermission,
|
||||||
|
repoName
|
||||||
|
}) => {
|
||||||
|
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
|
||||||
|
const [t] = useTranslation("repos");
|
||||||
|
|
||||||
|
const deletePermissionCallback = () => {
|
||||||
|
deletePermission(permission, namespace, repoName);
|
||||||
};
|
};
|
||||||
|
|
||||||
deletePermission = () => {
|
const confirmDelete = () => {
|
||||||
this.props.deletePermission(this.props.permission, this.props.namespace, this.props.repoName);
|
setShowConfirmAlert(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
confirmDelete = () => {
|
const isDeletable = () => {
|
||||||
const { t } = this.props;
|
return permission._links.delete;
|
||||||
confirmAlert({
|
|
||||||
title: t("permission.delete-permission-button.confirm-alert.title"),
|
|
||||||
message: t("permission.delete-permission-button.confirm-alert.message"),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
className: "is-outlined",
|
|
||||||
label: t("permission.delete-permission-button.confirm-alert.submit"),
|
|
||||||
onClick: () => this.deletePermission()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("permission.delete-permission-button.confirm-alert.cancel"),
|
|
||||||
onClick: () => null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
isDeletable = () => {
|
const action = confirmDialog ? confirmDelete : deletePermissionCallback;
|
||||||
return this.props.permission._links.delete;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
if (!isDeletable()) {
|
||||||
const { confirmDialog } = this.props;
|
return null;
|
||||||
const action = confirmDialog ? this.confirmDelete : this.deletePermission;
|
}
|
||||||
|
|
||||||
if (!this.isDeletable()) {
|
if (showConfirmAlert) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<a className="level-item" onClick={action}>
|
<ConfirmAlert
|
||||||
<span className="icon is-small">
|
title={t("permission.delete-permission-button.confirm-alert.title")}
|
||||||
<i className="fas fa-trash" />
|
message={t("permission.delete-permission-button.confirm-alert.message")}
|
||||||
</span>
|
buttons={[
|
||||||
</a>
|
{
|
||||||
|
className: "is-outlined",
|
||||||
|
label: t("permission.delete-permission-button.confirm-alert.submit"),
|
||||||
|
onClick: () => deletePermissionCallback()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("permission.delete-permission-button.confirm-alert.cancel"),
|
||||||
|
onClick: () => null
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
close={() => setShowConfirmAlert(false)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default withTranslation("repos")(DeletePermissionButton);
|
return (
|
||||||
|
<a className="level-item" onClick={action}>
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeletePermissionButton;
|
||||||
|
|||||||
145
scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx
Normal file
145
scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { FC, useEffect, useState } from "react";
|
||||||
|
import { apiClient, ErrorNotification, InputField, Level, Loading, SubmitButton } from "@scm-manager/ui-components";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CONTENT_TYPE_API_KEY } from "./SetApiKeys";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import {
|
||||||
|
fetchAvailablePermissionsIfNeeded,
|
||||||
|
getAvailableRepositoryRoles
|
||||||
|
} from "../../../repos/permissions/modules/permissions";
|
||||||
|
import { RepositoryRole } from "@scm-manager/ui-types";
|
||||||
|
import { getRepositoryRolesLink, getRepositoryVerbsLink } from "../../../modules/indexResource";
|
||||||
|
import RoleSelector from "../../../repos/permissions/components/RoleSelector";
|
||||||
|
import ApiKeyCreatedModal from "./ApiKeyCreatedModal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
createLink: string;
|
||||||
|
refresh: () => void;
|
||||||
|
repositoryRolesLink: string;
|
||||||
|
repositoryVerbsLink: string;
|
||||||
|
fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void;
|
||||||
|
availableRepositoryRoles?: RepositoryRole[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddApiKey: FC<Props> = ({
|
||||||
|
createLink,
|
||||||
|
refresh,
|
||||||
|
fetchAvailablePermissionsIfNeeded,
|
||||||
|
repositoryRolesLink,
|
||||||
|
repositoryVerbsLink,
|
||||||
|
availableRepositoryRoles
|
||||||
|
}) => {
|
||||||
|
const [t] = useTranslation("users");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<undefined | Error>();
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [permissionRole, setPermissionRole] = useState("");
|
||||||
|
const [addedKey, setAddedKey] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableRepositoryRoles) {
|
||||||
|
fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink);
|
||||||
|
}
|
||||||
|
}, [repositoryRolesLink, repositoryVerbsLink]);
|
||||||
|
|
||||||
|
const isValid = () => {
|
||||||
|
return !!displayName && !!permissionRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setDisplayName("");
|
||||||
|
setPermissionRole("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addKey = () => {
|
||||||
|
setLoading(true);
|
||||||
|
apiClient
|
||||||
|
.post(createLink, { displayName: displayName, permissionRole: permissionRole }, CONTENT_TYPE_API_KEY)
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(setAddedKey)
|
||||||
|
.then(() => setLoading(false))
|
||||||
|
.catch(setError);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorNotification error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRoleNames = availableRepositoryRoles ? availableRepositoryRoles.map(r => r.name) : [];
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
resetForm();
|
||||||
|
refresh();
|
||||||
|
setAddedKey("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const newKeyModal = addedKey && <ApiKeyCreatedModal addedKey={addedKey} close={closeModal} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{newKeyModal}
|
||||||
|
<InputField label={t("apiKey.displayName")} value={displayName} onChange={setDisplayName} />
|
||||||
|
<RoleSelector
|
||||||
|
loading={!availableRoleNames}
|
||||||
|
availableRoles={availableRoleNames}
|
||||||
|
label={t("apiKey.permissionRole.label")}
|
||||||
|
helpText={t("apiKey.permissionRole.help")}
|
||||||
|
handleRoleChange={setPermissionRole}
|
||||||
|
role={permissionRole}
|
||||||
|
/>
|
||||||
|
<Level
|
||||||
|
right={<SubmitButton label={t("apiKey.addKey")} loading={loading} disabled={!isValid()} action={addKey} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||||
|
const availableRepositoryRoles = getAvailableRepositoryRoles(state);
|
||||||
|
const repositoryRolesLink = getRepositoryRolesLink(state);
|
||||||
|
const repositoryVerbsLink = getRepositoryVerbsLink(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableRepositoryRoles,
|
||||||
|
repositoryRolesLink,
|
||||||
|
repositoryVerbsLink
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: any) => {
|
||||||
|
return {
|
||||||
|
fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => {
|
||||||
|
dispatch(fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AddApiKey);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { FC, useRef, useState } from "react";
|
||||||
|
import { Button, Icon, Modal } from "@scm-manager/ui-components";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
addedKey: string;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KeyArea = styled.textarea`
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro", monospace;
|
||||||
|
height: 3rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NoLeftMargin = styled.div`
|
||||||
|
margin-left: -1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ApiKeyCreatedModal: FC<Props> = ({ addedKey, close }) => {
|
||||||
|
const [t] = useTranslation("users");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const keyRef = useRef(null);
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
keyRef.current.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
setCopied(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPassphraseModalContent = (
|
||||||
|
<div className={"media-content"}>
|
||||||
|
<p>{t("apiKey.modal.text1")}</p>
|
||||||
|
<p>
|
||||||
|
<b>{t("apiKey.modal.text2")}</b>
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<div className={"columns"}>
|
||||||
|
<div className={"column is-11"}>
|
||||||
|
<KeyArea wrap={"soft"} ref={keyRef} className={"input"} value={addedKey} />
|
||||||
|
</div>
|
||||||
|
<NoLeftMargin className={"column is-1"}>
|
||||||
|
<Icon className={"is-hidden-mobile fa-2x"} name={copied ? "clipboard-check" : "clipboard"} title={t("apiKey.modal.clipboard")} onClick={copy} />
|
||||||
|
</NoLeftMargin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
body={newPassphraseModalContent}
|
||||||
|
closeFunction={close}
|
||||||
|
title={t("apiKey.modal.title")}
|
||||||
|
footer={<Button label={t("apiKey.modal.close")} action={close} />}
|
||||||
|
active={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyCreatedModal;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { FC } from "react";
|
||||||
|
import { DateFromNow, Icon } from "@scm-manager/ui-components";
|
||||||
|
import { ApiKey } from "./SetApiKeys";
|
||||||
|
import { Link } from "@scm-manager/ui-types";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
apiKey: ApiKey;
|
||||||
|
onDelete: (link: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApiKeyEntry: FC<Props> = ({ apiKey, onDelete }) => {
|
||||||
|
const [t] = useTranslation("users");
|
||||||
|
let deleteButton;
|
||||||
|
if (apiKey?._links?.delete) {
|
||||||
|
deleteButton = (
|
||||||
|
<a className="level-item" onClick={() => onDelete((apiKey._links.delete as Link).href)}>
|
||||||
|
<span className="icon is-small">
|
||||||
|
<Icon name="trash" className="fas" title={t("apiKey.delete")} />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>{apiKey.displayName}</td>
|
||||||
|
<td>{apiKey.permissionRole}</td>
|
||||||
|
<td className="is-hidden-mobile">
|
||||||
|
<DateFromNow date={apiKey.created}/>
|
||||||
|
</td>
|
||||||
|
<td className="is-darker">{deleteButton}</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyEntry;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ApiKey, ApiKeysCollection } from "./SetApiKeys";
|
||||||
|
import ApiKeyEntry from "./ApiKeyEntry";
|
||||||
|
import { Notification } from "@scm-manager/ui-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
apiKeys?: ApiKeysCollection;
|
||||||
|
onDelete: (link: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiKeyTable: FC<Props> = ({ apiKeys, onDelete }) => {
|
||||||
|
const [t] = useTranslation("users");
|
||||||
|
|
||||||
|
if (apiKeys?._embedded?.keys?.length === 0) {
|
||||||
|
return <Notification type="info">{t("apiKey.noStoredKeys")}</Notification>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="card-table table is-hoverable is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("apiKey.displayName")}</th>
|
||||||
|
<th>{t("apiKey.permissionRole.label")}</th>
|
||||||
|
<th>{t("apiKey.created")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apiKeys?._embedded?.keys?.map((apiKey: ApiKey, index: number) => {
|
||||||
|
return <ApiKeyEntry key={index} onDelete={onDelete} apiKey={apiKey} />;
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyTable;
|
||||||
110
scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx
Normal file
110
scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Collection, Links, User, Me } from "@scm-manager/ui-types";
|
||||||
|
import React, { FC, useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
|
||||||
|
import ApiKeyTable from "./ApiKeyTable";
|
||||||
|
import AddApiKey from "./AddApiKey";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
export type ApiKeysCollection = Collection & {
|
||||||
|
_embedded: {
|
||||||
|
keys: ApiKey[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKey = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
permissionRole: string;
|
||||||
|
created: string;
|
||||||
|
_links: Links;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTENT_TYPE_API_KEY = "application/vnd.scmm-apiKey+json;v=2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: User | Me;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Subtitle = styled.div`
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SetApiKeys: FC<Props> = ({ user }) => {
|
||||||
|
const [t] = useTranslation("users");
|
||||||
|
const [error, setError] = useState<undefined | Error>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [apiKeys, setApiKeys] = useState<ApiKeysCollection | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApiKeys();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const fetchApiKeys = () => {
|
||||||
|
setLoading(true);
|
||||||
|
apiClient
|
||||||
|
.get((user._links.apiKeys as Link).href)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setApiKeys)
|
||||||
|
.then(() => setLoading(false))
|
||||||
|
.catch(setError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = (link: string) => {
|
||||||
|
apiClient
|
||||||
|
.delete(link)
|
||||||
|
.then(fetchApiKeys)
|
||||||
|
.catch(setError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLink = (apiKeys?._links?.create as Link)?.href;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorNotification error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={"media-content"}>
|
||||||
|
<p>{t("apiKey.text1")} <Link to={"/admin/roles/"}>{t("apiKey.manageRoles")}</Link></p>
|
||||||
|
<p>{t("apiKey.text2")}</p>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<ApiKeyTable apiKeys={apiKeys} onDelete={onDelete} />
|
||||||
|
<hr />
|
||||||
|
<Subtitle className={"media-content"}><h2 className={"title is-4"}>Create new key</h2></Subtitle>
|
||||||
|
{createLink && <AddApiKey createLink={createLink} refresh={fetchApiKeys} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SetApiKeys;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
import React, { FC } from "react";
|
||||||
|
import { Link, User, Me } from "@scm-manager/ui-types";
|
||||||
|
import { NavLink } from "@scm-manager/ui-components";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: User | Me;
|
||||||
|
apiKeyUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SetApiKeyNavLink: FC<Props> = ({ user, apiKeyUrl }) => {
|
||||||
|
const [t] = useTranslation("users");
|
||||||
|
|
||||||
|
if ((user?._links?.apiKeys as Link)?.href) {
|
||||||
|
return <NavLink to={apiKeyUrl} label={t("singleUser.menu.setApiKeyNavLink")} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SetApiKeyNavLink;
|
||||||
@@ -21,80 +21,78 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React, { FC, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { compose } from "redux";
|
import { useHistory } from "react-router-dom";
|
||||||
import { withRouter } from "react-router-dom";
|
import { useTranslation } from "react-i18next";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
|
||||||
import { History } from "history";
|
|
||||||
import { User } from "@scm-manager/ui-types";
|
import { User } from "@scm-manager/ui-types";
|
||||||
import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
|
||||||
import { deleteUser, getDeleteUserFailure, isDeleteUserPending } from "../modules/users";
|
import { deleteUser, getDeleteUserFailure, isDeleteUserPending } from "../modules/users";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error;
|
error: Error;
|
||||||
user: User;
|
user: User;
|
||||||
confirmDialog?: boolean;
|
confirmDialog?: boolean;
|
||||||
deleteUser: (user: User, callback?: () => void) => void;
|
deleteUser: (user: User, callback?: () => void) => void;
|
||||||
|
|
||||||
// context props
|
|
||||||
history: History;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class DeleteUser extends React.Component<Props> {
|
const DeleteUser: FC<Props> = ({ confirmDialog = true, loading, error, user, deleteUser }) => {
|
||||||
static defaultProps = {
|
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
|
||||||
confirmDialog: true
|
const [t] = useTranslation("users");
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const userDeleted = () => {
|
||||||
|
history.push("/users/");
|
||||||
};
|
};
|
||||||
|
|
||||||
userDeleted = () => {
|
const deleteUserCallback = () => {
|
||||||
this.props.history.push("/users/");
|
deleteUser(user, userDeleted);
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteUser = () => {
|
const confirmDelete = () => {
|
||||||
this.props.deleteUser(this.props.user, this.userDeleted);
|
setShowConfirmAlert(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
confirmDelete = () => {
|
const isDeletable = () => {
|
||||||
const { t } = this.props;
|
return user._links.delete;
|
||||||
confirmAlert({
|
|
||||||
title: t("deleteUser.confirmAlert.title"),
|
|
||||||
message: t("deleteUser.confirmAlert.message"),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
className: "is-outlined",
|
|
||||||
label: t("deleteUser.confirmAlert.submit"),
|
|
||||||
onClick: () => this.deleteUser()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("deleteUser.confirmAlert.cancel"),
|
|
||||||
onClick: () => null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
isDeletable = () => {
|
const action = confirmDialog ? confirmDelete : deleteUserCallback;
|
||||||
return this.props.user._links.delete;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
if (!isDeletable()) {
|
||||||
const { loading, error, confirmDialog, t } = this.props;
|
return null;
|
||||||
const action = confirmDialog ? this.confirmDelete : this.deleteUser;
|
}
|
||||||
|
|
||||||
if (!this.isDeletable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (showConfirmAlert) {
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfirmAlert
|
||||||
<hr />
|
title={t("deleteUser.confirmAlert.title")}
|
||||||
<ErrorNotification error={error} />
|
message={t("deleteUser.confirmAlert.message")}
|
||||||
<Level right={<DeleteButton label={t("deleteUser.button")} action={action} loading={loading} />} />
|
buttons={[
|
||||||
</>
|
{
|
||||||
|
className: "is-outlined",
|
||||||
|
label: t("deleteUser.confirmAlert.submit"),
|
||||||
|
onClick: () => deleteUserCallback()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("deleteUser.confirmAlert.cancel"),
|
||||||
|
onClick: () => null
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
close={() => setShowConfirmAlert(false)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<ErrorNotification error={error} />
|
||||||
|
<Level right={<DeleteButton label={t("deleteUser.button")} action={action} loading={loading} />} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||||
const loading = isDeleteUserPending(state, ownProps.user.name);
|
const loading = isDeleteUserPending(state, ownProps.user.name);
|
||||||
@@ -113,4 +111,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default compose(connect(mapStateToProps, mapDispatchToProps), withRouter, withTranslation("users"))(DeleteUser);
|
export default connect(mapStateToProps, mapDispatchToProps)(DeleteUser);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api;
|
package sonia.scm.api;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -42,7 +42,7 @@ public class NotSupportedExceptionMapper implements ExceptionMapper<NotSupported
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Response toResponse(NotSupportedException exception) {
|
public Response toResponse(NotSupportedException exception) {
|
||||||
LOG.debug("illegal media type");
|
LOG.debug("illegal media type", exception);
|
||||||
ErrorDto error = new ErrorDto();
|
ErrorDto error = new ErrorDto();
|
||||||
error.setTransactionId(MDC.get("transaction_id"));
|
error.setTransactionId(MDC.get("transaction_id"));
|
||||||
error.setMessage("illegal media type");
|
error.setMessage("illegal media type");
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
|
import de.otto.edison.hal.Embedded;
|
||||||
|
import de.otto.edison.hal.HalRepresentation;
|
||||||
|
import de.otto.edison.hal.Links;
|
||||||
|
import sonia.scm.security.ApiKey;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static de.otto.edison.hal.Link.link;
|
||||||
|
import static java.util.stream.Collectors.toList;
|
||||||
|
|
||||||
|
public class ApiKeyCollectionToDtoMapper {
|
||||||
|
|
||||||
|
private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper;
|
||||||
|
private final ResourceLinks resourceLinks;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ApiKeyCollectionToDtoMapper(ApiKeyToApiKeyDtoMapper apiKeyDtoMapper, ResourceLinks resourceLinks) {
|
||||||
|
this.apiKeyDtoMapper = apiKeyDtoMapper;
|
||||||
|
this.resourceLinks = resourceLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HalRepresentation map(Collection<ApiKey> keys) {
|
||||||
|
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
|
||||||
|
final Links.Builder links = Links.linkingTo()
|
||||||
|
.self(resourceLinks.apiKeyCollection().self())
|
||||||
|
.single(link("create", resourceLinks.apiKeyCollection().create()));
|
||||||
|
return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ApiKeyDto extends HalRepresentation {
|
||||||
|
@NotEmpty
|
||||||
|
private String displayName;
|
||||||
|
@NotEmpty
|
||||||
|
private String permissionRole;
|
||||||
|
private Instant created;
|
||||||
|
|
||||||
|
public ApiKeyDto(Links links) {
|
||||||
|
super(links);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
|
import de.otto.edison.hal.HalRepresentation;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.headers.Header;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import sonia.scm.ContextEntry;
|
||||||
|
import sonia.scm.security.ApiKey;
|
||||||
|
import sonia.scm.security.ApiKeyService;
|
||||||
|
import sonia.scm.web.VndMediaType;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import javax.ws.rs.Consumes;
|
||||||
|
import javax.ws.rs.DELETE;
|
||||||
|
import javax.ws.rs.GET;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.PathParam;
|
||||||
|
import javax.ws.rs.Produces;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
import static javax.ws.rs.core.Response.Status.CREATED;
|
||||||
|
import static sonia.scm.NotFoundException.notFound;
|
||||||
|
|
||||||
|
public class ApiKeyResource {
|
||||||
|
|
||||||
|
private final ApiKeyService apiKeyService;
|
||||||
|
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
|
||||||
|
private final ApiKeyToApiKeyDtoMapper apiKeyMapper;
|
||||||
|
private final ResourceLinks resourceLinks;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper, ResourceLinks links) {
|
||||||
|
this.apiKeyService = apiKeyService;
|
||||||
|
this.apiKeyCollectionMapper = apiKeyCollectionMapper;
|
||||||
|
this.apiKeyMapper = apiKeyMapper;
|
||||||
|
this.resourceLinks = links;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("")
|
||||||
|
@Produces(VndMediaType.API_KEY_COLLECTION)
|
||||||
|
@Operation(summary = "Get the api keys for the current user", description = "Returns the registered api keys for the logged in user.", tags = "User")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "success",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = VndMediaType.API_KEY_COLLECTION,
|
||||||
|
schema = @Schema(implementation = HalRepresentation.class)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "internal server error",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = VndMediaType.ERROR_TYPE,
|
||||||
|
schema = @Schema(implementation = ErrorDto.class)
|
||||||
|
))
|
||||||
|
public HalRepresentation getForCurrentUser() {
|
||||||
|
return apiKeyCollectionMapper.map(apiKeyService.getKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{id}")
|
||||||
|
@Produces(VndMediaType.API_KEY)
|
||||||
|
@Operation(summary = "Get one api key for the current user", description = "Returns the registered api key with the given id for the logged in user.", tags = "User")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "success",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = VndMediaType.API_KEY,
|
||||||
|
schema = @Schema(implementation = HalRepresentation.class)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "404",
|
||||||
|
description = "not found, no api key with the given id for the current user available",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = VndMediaType.ERROR_TYPE,
|
||||||
|
schema = @Schema(implementation = ErrorDto.class)
|
||||||
|
))
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "internal server error",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = VndMediaType.ERROR_TYPE,
|
||||||
|
schema = @Schema(implementation = ErrorDto.class)
|
||||||
|
))
|
||||||
|
public ApiKeyDto get(@PathParam("id") String id) {
|
||||||
|
return apiKeyService
|
||||||
|
.getKeys()
|
||||||
|
.stream()
|
||||||
|
.filter(key -> key.getId().equals(id))
|
||||||
|
.map(apiKeyMapper::map).findAny()
|
||||||
|
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("")
|
||||||
|
@Consumes(VndMediaType.API_KEY)
|
||||||
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
|
@Operation(summary = "Create new api key for the current user", description = "Creates a new api key for the given user with the role specified in the given key.", tags = "User")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "201",
|
||||||
|
description = "create success",
|
||||||
|
headers = @Header(
|
||||||
|
name = "Location",
|
||||||
|
description = "uri to the created user",
|
||||||
|
schema = @Schema(type = "string")
|
||||||
|
),
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||||
|
@ApiResponse(responseCode = "409", description = "conflict, a key with the given display name already exists")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "internal server error",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = VndMediaType.ERROR_TYPE,
|
||||||
|
schema = @Schema(implementation = ErrorDto.class)
|
||||||
|
))
|
||||||
|
public Response create(@Valid ApiKeyDto apiKey) {
|
||||||
|
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getPermissionRole());
|
||||||
|
return Response.status(CREATED)
|
||||||
|
.entity(newKey.getToken())
|
||||||
|
.location(URI.create(resourceLinks.apiKey().self(newKey.getId())))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("{id}")
|
||||||
|
@Operation(summary = "Delete api key", description = "Deletes the api key with the given id for the current user.", tags = "User")
|
||||||
|
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
|
||||||
|
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||||
|
@ApiResponse(responseCode = "500", description = "internal server error")
|
||||||
|
public void delete(@PathParam("id") String id) {
|
||||||
|
apiKeyService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
|
import de.otto.edison.hal.Links;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.ObjectFactory;
|
||||||
|
import sonia.scm.security.ApiKey;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
|
||||||
|
import static de.otto.edison.hal.Link.link;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public abstract class ApiKeyToApiKeyDtoMapper {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private ResourceLinks resourceLinks;
|
||||||
|
|
||||||
|
abstract ApiKeyDto map(ApiKey key);
|
||||||
|
|
||||||
|
@ObjectFactory
|
||||||
|
ApiKeyDto createDto(ApiKey key) {
|
||||||
|
Links.Builder links = Links.linkingTo()
|
||||||
|
.self(resourceLinks.apiKey().self(key.getId()))
|
||||||
|
.single(link("delete", resourceLinks.apiKey().delete(key.getId())));
|
||||||
|
return new ApiKeyDto(links.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import de.otto.edison.hal.Embedded;
|
import de.otto.edison.hal.Embedded;
|
||||||
@@ -61,8 +61,10 @@ public abstract class BrowserResultToFileObjectDtoMapper extends BaseFileObjectD
|
|||||||
@Override
|
@Override
|
||||||
void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) {
|
void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) {
|
||||||
EdisonHalAppender appender = new EdisonHalAppender(links, embeddedBuilder);
|
EdisonHalAppender appender = new EdisonHalAppender(links, embeddedBuilder);
|
||||||
// we call enrichers, which are only responsible for top level browseresults
|
if (browserResult.getFile().equals(fileObject)) {
|
||||||
applyEnrichers(appender, browserResult, namespaceAndName);
|
// we call enrichers, which are only responsible for top level browseresults
|
||||||
|
applyEnrichers(appender, browserResult, namespaceAndName);
|
||||||
|
}
|
||||||
// we call enrichers, which are responsible for all file object top level browse result and its children
|
// we call enrichers, which are responsible for all file object top level browse result and its children
|
||||||
applyEnrichers(appender, fileObject, namespaceAndName, browserResult, browserResult.getRevision());
|
applyEnrichers(appender, fileObject, namespaceAndName, browserResult, browserResult.getRevision());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,5 +86,7 @@ public class MapperModule extends AbstractModule {
|
|||||||
bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST);
|
bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST);
|
||||||
|
|
||||||
bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class));
|
bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class));
|
||||||
|
|
||||||
|
bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ public class MeDtoFactory extends HalAppenderMapper {
|
|||||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
||||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||||
}
|
}
|
||||||
|
if (UserPermissions.changeApiKeys(user).isPermitted()) {
|
||||||
|
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
|
||||||
|
}
|
||||||
|
|
||||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
|
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
@@ -35,6 +35,7 @@ import sonia.scm.user.UserManager;
|
|||||||
import sonia.scm.web.VndMediaType;
|
import sonia.scm.web.VndMediaType;
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
|
import javax.inject.Provider;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
@@ -61,11 +62,14 @@ public class MeResource {
|
|||||||
private final UserManager userManager;
|
private final UserManager userManager;
|
||||||
private final PasswordService passwordService;
|
private final PasswordService passwordService;
|
||||||
|
|
||||||
|
private final Provider<ApiKeyResource> apiKeyResource;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) {
|
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
|
||||||
this.meDtoFactory = meDtoFactory;
|
this.meDtoFactory = meDtoFactory;
|
||||||
this.userManager = userManager;
|
this.userManager = userManager;
|
||||||
this.passwordService = passwordService;
|
this.passwordService = passwordService;
|
||||||
|
this.apiKeyResource = apiKeyResource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,4 +122,9 @@ public class MeResource {
|
|||||||
);
|
);
|
||||||
return Response.noContent().build();
|
return Response.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Path("api_keys")
|
||||||
|
public ApiKeyResource apiKeys() {
|
||||||
|
return apiKeyResource.get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,46 @@ class ResourceLinks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ApiKeyCollectionLinks apiKeyCollection() {
|
||||||
|
return new ApiKeyCollectionLinks(scmPathInfoStore.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ApiKeyCollectionLinks {
|
||||||
|
private final LinkBuilder collectionLinkBuilder;
|
||||||
|
|
||||||
|
ApiKeyCollectionLinks(ScmPathInfo pathInfo) {
|
||||||
|
this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
String self() {
|
||||||
|
return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href();
|
||||||
|
}
|
||||||
|
|
||||||
|
String create() {
|
||||||
|
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters().href();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiKeyLinks apiKey() {
|
||||||
|
return new ApiKeyLinks(scmPathInfoStore.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ApiKeyLinks {
|
||||||
|
private final LinkBuilder apiKeyLinkBuilder;
|
||||||
|
|
||||||
|
ApiKeyLinks(ScmPathInfo pathInfo) {
|
||||||
|
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
String self(String id) {
|
||||||
|
return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href();
|
||||||
|
}
|
||||||
|
|
||||||
|
String delete(String id) {
|
||||||
|
return apiKeyLinkBuilder.method("apiKeys").parameters().method("delete").parameters(id).href();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UserCollectionLinks userCollection() {
|
UserCollectionLinks userCollection() {
|
||||||
return new UserCollectionLinks(scmPathInfoStore.get());
|
return new UserCollectionLinks(scmPathInfoStore.get());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -131,7 +131,7 @@ public class UserResource {
|
|||||||
*
|
*
|
||||||
* <strong>Note:</strong> This method requires "user" privilege.
|
* <strong>Note:</strong> This method requires "user" privilege.
|
||||||
*
|
*
|
||||||
* @param name name of the user to be modified
|
* @param name name of the user to be modified
|
||||||
* @param user user object to modify
|
* @param user user object to modify
|
||||||
*/
|
*/
|
||||||
@PUT
|
@PUT
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.lifecycle.modules;
|
package sonia.scm.lifecycle.modules;
|
||||||
|
|
||||||
//~--- non-JDK imports --------------------------------------------------------
|
//~--- non-JDK imports --------------------------------------------------------
|
||||||
@@ -33,6 +33,7 @@ import org.apache.shiro.authc.credential.DefaultPasswordService;
|
|||||||
import org.apache.shiro.authc.credential.PasswordService;
|
import org.apache.shiro.authc.credential.PasswordService;
|
||||||
import org.apache.shiro.authc.pam.AuthenticationStrategy;
|
import org.apache.shiro.authc.pam.AuthenticationStrategy;
|
||||||
import org.apache.shiro.authc.pam.ModularRealmAuthenticator;
|
import org.apache.shiro.authc.pam.ModularRealmAuthenticator;
|
||||||
|
import org.apache.shiro.authz.permission.PermissionResolver;
|
||||||
import org.apache.shiro.crypto.hash.DefaultHashService;
|
import org.apache.shiro.crypto.hash.DefaultHashService;
|
||||||
import org.apache.shiro.guice.web.ShiroWebModule;
|
import org.apache.shiro.guice.web.ShiroWebModule;
|
||||||
import org.apache.shiro.realm.Realm;
|
import org.apache.shiro.realm.Realm;
|
||||||
@@ -48,6 +49,7 @@ import javax.servlet.ServletContext;
|
|||||||
import org.apache.shiro.mgt.RememberMeManager;
|
import org.apache.shiro.mgt.RememberMeManager;
|
||||||
import sonia.scm.security.DisabledRememberMeManager;
|
import sonia.scm.security.DisabledRememberMeManager;
|
||||||
import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy;
|
import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy;
|
||||||
|
import sonia.scm.security.ScmPermissionResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -94,7 +96,7 @@ public class ScmSecurityModule extends ShiroWebModule
|
|||||||
|
|
||||||
// expose password service to global injector
|
// expose password service to global injector
|
||||||
expose(PasswordService.class);
|
expose(PasswordService.class);
|
||||||
|
|
||||||
// disable remember me cookie generation
|
// disable remember me cookie generation
|
||||||
bind(RememberMeManager.class).to(DisabledRememberMeManager.class);
|
bind(RememberMeManager.class).to(DisabledRememberMeManager.class);
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ public class ScmSecurityModule extends ShiroWebModule
|
|||||||
bind(ModularRealmAuthenticator.class);
|
bind(ModularRealmAuthenticator.class);
|
||||||
bind(Authenticator.class).to(ModularRealmAuthenticator.class);
|
bind(Authenticator.class).to(ModularRealmAuthenticator.class);
|
||||||
bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class);
|
bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class);
|
||||||
|
bind(PermissionResolver.class).to(ScmPermissionResolver.class);
|
||||||
|
|
||||||
// bind realm
|
// bind realm
|
||||||
for (Class<? extends Realm> realm : extensionProcessor.byExtensionPoint(Realm.class))
|
for (Class<? extends Realm> realm : extensionProcessor.byExtensionPoint(Realm.class))
|
||||||
@@ -116,7 +119,7 @@ public class ScmSecurityModule extends ShiroWebModule
|
|||||||
|
|
||||||
// disable access to mustache resources
|
// disable access to mustache resources
|
||||||
addFilterChain("/**.mustache", filterConfig(ROLES, "nobody"));
|
addFilterChain("/**.mustache", filterConfig(ROLES, "nobody"));
|
||||||
|
|
||||||
// disable session
|
// disable session
|
||||||
addFilterChain("/**", NO_SESSION_CREATION);
|
addFilterChain("/**", NO_SESSION_CREATION);
|
||||||
}
|
}
|
||||||
|
|||||||
48
scm-webapp/src/main/java/sonia/scm/security/ApiKey.java
Normal file
48
scm-webapp/src/main/java/sonia/scm/security/ApiKey.java
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiKey {
|
||||||
|
private final String id;
|
||||||
|
private final String displayName;
|
||||||
|
private final String permissionRole;
|
||||||
|
private final Instant created;
|
||||||
|
|
||||||
|
ApiKey(ApiKeyWithPassphrase apiKeyWithPassphrase) {
|
||||||
|
this(
|
||||||
|
apiKeyWithPassphrase.getId(),
|
||||||
|
apiKeyWithPassphrase.getDisplayName(),
|
||||||
|
apiKeyWithPassphrase.getPermissionRole(),
|
||||||
|
apiKeyWithPassphrase.getCreated()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.apache.commons.collections.CollectionUtils;
|
||||||
|
|
||||||
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
import javax.xml.bind.annotation.XmlElement;
|
||||||
|
import javax.xml.bind.annotation.XmlRootElement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
import static java.util.Collections.singletonList;
|
||||||
|
import static java.util.stream.Collectors.toList;
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
|
@Getter
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
@XmlRootElement(name = "keys")
|
||||||
|
class ApiKeyCollection {
|
||||||
|
@XmlElement(name = "key")
|
||||||
|
private Collection<ApiKeyWithPassphrase> keys;
|
||||||
|
|
||||||
|
public ApiKeyCollection add(ApiKeyWithPassphrase key) {
|
||||||
|
Collection<ApiKeyWithPassphrase> newKeys;
|
||||||
|
if (CollectionUtils.isEmpty(keys)) {
|
||||||
|
newKeys = singletonList(key);
|
||||||
|
} else {
|
||||||
|
newKeys = new ArrayList<>(keys.size() + 1);
|
||||||
|
newKeys.addAll(keys);
|
||||||
|
newKeys.add(key);
|
||||||
|
}
|
||||||
|
return new ApiKeyCollection(newKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiKeyCollection remove(Predicate<ApiKeyWithPassphrase> predicate) {
|
||||||
|
Collection<ApiKeyWithPassphrase> newKeys = keys.stream().filter(key -> !predicate.test(key)).collect(toList());
|
||||||
|
return new ApiKeyCollection(newKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java
Normal file
111
scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import org.apache.shiro.authc.AuthenticationInfo;
|
||||||
|
import org.apache.shiro.authc.AuthenticationToken;
|
||||||
|
import org.apache.shiro.authc.UsernamePasswordToken;
|
||||||
|
import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher;
|
||||||
|
import org.apache.shiro.authz.AuthorizationException;
|
||||||
|
import org.apache.shiro.realm.AuthenticatingRealm;
|
||||||
|
import sonia.scm.plugin.Extension;
|
||||||
|
import sonia.scm.repository.RepositoryRole;
|
||||||
|
import sonia.scm.repository.RepositoryRoleManager;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import javax.inject.Singleton;
|
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkArgument;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Extension
|
||||||
|
public class ApiKeyRealm extends AuthenticatingRealm {
|
||||||
|
|
||||||
|
private final ApiKeyService apiKeyService;
|
||||||
|
private final DAORealmHelper helper;
|
||||||
|
private final RepositoryRoleManager repositoryRoleManager;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory, RepositoryRoleManager repositoryRoleManager) {
|
||||||
|
this.apiKeyService = apiKeyService;
|
||||||
|
this.helper = helperFactory.create("ApiTokenRealm");
|
||||||
|
this.repositoryRoleManager = repositoryRoleManager;
|
||||||
|
setAuthenticationTokenClass(BearerToken.class);
|
||||||
|
setCredentialsMatcher(new AllowAllCredentialsMatcher());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(AuthenticationToken token) {
|
||||||
|
return token instanceof UsernamePasswordToken || token instanceof BearerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
|
||||||
|
checkArgument(
|
||||||
|
token instanceof BearerToken || token instanceof UsernamePasswordToken,
|
||||||
|
"%s is required", BearerToken.class);
|
||||||
|
String password = getPassword(token);
|
||||||
|
ApiKeyService.CheckResult check = apiKeyService.check(password);
|
||||||
|
return buildAuthenticationInfo(token, check);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthenticationInfo buildAuthenticationInfo(AuthenticationToken token, ApiKeyService.CheckResult check) {
|
||||||
|
RepositoryRole repositoryRole = determineRole(check);
|
||||||
|
Scope scope = createScope(repositoryRole);
|
||||||
|
return helper
|
||||||
|
.authenticationInfoBuilder(check.getUser())
|
||||||
|
.withSessionId(getPrincipal(token))
|
||||||
|
.withScope(scope)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getPassword(AuthenticationToken token) {
|
||||||
|
if (token instanceof BearerToken) {
|
||||||
|
return ((BearerToken) token).getCredentials();
|
||||||
|
} else {
|
||||||
|
return new String(((UsernamePasswordToken) token).getPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RepositoryRole determineRole(ApiKeyService.CheckResult check) {
|
||||||
|
RepositoryRole repositoryRole = repositoryRoleManager.get(check.getPermissionRole());
|
||||||
|
if (repositoryRole == null) {
|
||||||
|
throw new AuthorizationException("api key has unknown role: " + check.getPermissionRole());
|
||||||
|
}
|
||||||
|
return repositoryRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Scope createScope(RepositoryRole repositoryRole) {
|
||||||
|
return Scope.valueOf("repository:" + String.join(",", repositoryRole.getVerbs()) + ":*");
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionId getPrincipal(AuthenticationToken token) {
|
||||||
|
if (token instanceof BearerToken) {
|
||||||
|
return ((BearerToken) token).getPrincipal();
|
||||||
|
} else {
|
||||||
|
return SessionId.valueOf((token.getPrincipal()).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java
Normal file
219
scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import com.github.legman.Subscribe;
|
||||||
|
import com.google.common.util.concurrent.Striped;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.shiro.authc.credential.PasswordService;
|
||||||
|
import org.apache.shiro.authz.AuthorizationException;
|
||||||
|
import org.apache.shiro.util.ThreadContext;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import sonia.scm.ContextEntry;
|
||||||
|
import sonia.scm.HandlerEventType;
|
||||||
|
import sonia.scm.store.DataStore;
|
||||||
|
import sonia.scm.store.DataStoreFactory;
|
||||||
|
import sonia.scm.user.UserEvent;
|
||||||
|
import sonia.scm.user.UserPermissions;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.concurrent.locks.Lock;
|
||||||
|
import java.util.concurrent.locks.ReadWriteLock;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static java.time.Instant.now;
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static java.util.stream.Collectors.toList;
|
||||||
|
import static org.apache.commons.lang.RandomStringUtils.random;
|
||||||
|
import static sonia.scm.AlreadyExistsException.alreadyExists;
|
||||||
|
|
||||||
|
public class ApiKeyService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ApiKeyService.class);
|
||||||
|
private static final int PASSPHRASE_LENGTH = 20;
|
||||||
|
|
||||||
|
private final DataStore<ApiKeyCollection> store;
|
||||||
|
private final PasswordService passwordService;
|
||||||
|
private final KeyGenerator keyGenerator;
|
||||||
|
private final Supplier<String> passphraseGenerator;
|
||||||
|
private final ApiKeyTokenHandler tokenHandler;
|
||||||
|
|
||||||
|
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ApiKeyService(DataStoreFactory storeFactory, KeyGenerator keyGenerator, PasswordService passwordService, ApiKeyTokenHandler tokenHandler) {
|
||||||
|
this(storeFactory, passwordService, keyGenerator, tokenHandler, () -> random(PASSPHRASE_LENGTH, 0, 0, true, true, null, new SecureRandom()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiKeyService(DataStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, ApiKeyTokenHandler tokenHandler, Supplier<String> passphraseGenerator) {
|
||||||
|
this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
|
||||||
|
this.passwordService = passwordService;
|
||||||
|
this.keyGenerator = keyGenerator;
|
||||||
|
this.tokenHandler = tokenHandler;
|
||||||
|
this.passphraseGenerator = passphraseGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreationResult createNewKey(String name, String permissionRole) {
|
||||||
|
String user = currentUser();
|
||||||
|
UserPermissions.changeApiKeys(user).check();
|
||||||
|
String passphrase = passphraseGenerator.get();
|
||||||
|
String hashedPassphrase = passwordService.encryptPassword(passphrase);
|
||||||
|
String id = keyGenerator.createKey();
|
||||||
|
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, name, permissionRole, hashedPassphrase, now());
|
||||||
|
doSynchronized(user, true, () -> {
|
||||||
|
persistKey(name, user, key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
String token = tokenHandler.createToken(user, new ApiKey(key), passphrase);
|
||||||
|
LOG.info("created new api key for user {} with role {}", user, permissionRole);
|
||||||
|
return new CreationResult(token, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void persistKey(String name, String user, ApiKeyWithPassphrase key) {
|
||||||
|
if (containsName(user, name)) {
|
||||||
|
throw alreadyExists(ContextEntry.ContextBuilder.entity(ApiKeyWithPassphrase.class, name));
|
||||||
|
}
|
||||||
|
ApiKeyCollection apiKeyCollection = store.getOptional(user).orElse(new ApiKeyCollection(emptyList()));
|
||||||
|
ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(key);
|
||||||
|
store.put(user, newApiKeyCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void remove(String id) {
|
||||||
|
String user = currentUser();
|
||||||
|
UserPermissions.changeApiKeys(user).check();
|
||||||
|
doSynchronized(user, true, () -> {
|
||||||
|
if (!containsId(user, id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
store.getOptional(user).ifPresent(
|
||||||
|
apiKeyCollection -> {
|
||||||
|
ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId()));
|
||||||
|
store.put(user, newApiKeyCollection);
|
||||||
|
LOG.info("removed api key for user {}", user);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckResult check(String tokenAsString) {
|
||||||
|
return check(tokenHandler.readToken(tokenAsString)
|
||||||
|
.orElseThrow(AuthorizationException::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CheckResult check(ApiKeyTokenHandler.Token token) {
|
||||||
|
return check(token.getUser(), token.getApiKeyId(), token.getPassphrase());
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckResult check(String user, String id, String passphrase) {
|
||||||
|
return doSynchronized(user, false, () -> store
|
||||||
|
.get(user)
|
||||||
|
.getKeys()
|
||||||
|
.stream()
|
||||||
|
.filter(key -> key.getId().equals(id))
|
||||||
|
.filter(key -> passwordsMatch(user, passphrase, key))
|
||||||
|
.map(ApiKeyWithPassphrase::getPermissionRole)
|
||||||
|
.map(role -> new CheckResult(user, role))
|
||||||
|
.findAny()
|
||||||
|
.orElseThrow(AuthorizationException::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean passwordsMatch(String user, String passphrase, ApiKeyWithPassphrase key) {
|
||||||
|
boolean result = passwordService.passwordsMatch(passphrase, key.getPassphrase());
|
||||||
|
if (!result) {
|
||||||
|
// this can only happen with a forged api key, so it may be relevant enough to issue a warning
|
||||||
|
LOG.warn("got invalid api key for user {} with key id {}", user, key.getId());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<ApiKey> getKeys() {
|
||||||
|
return store.getOptional(currentUser())
|
||||||
|
.map(ApiKeyCollection::getKeys)
|
||||||
|
.map(Collection::stream)
|
||||||
|
.orElse(Stream.empty())
|
||||||
|
.map(ApiKey::new)
|
||||||
|
.collect(toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentUser() {
|
||||||
|
return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsId(String user, String id) {
|
||||||
|
return store
|
||||||
|
.getOptional(user)
|
||||||
|
.map(ApiKeyCollection::getKeys)
|
||||||
|
.orElse(emptyList())
|
||||||
|
.stream()
|
||||||
|
.anyMatch(key -> key.getId().equals(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsName(String user, String name) {
|
||||||
|
return store
|
||||||
|
.getOptional(user)
|
||||||
|
.map(ApiKeyCollection::getKeys)
|
||||||
|
.orElse(emptyList())
|
||||||
|
.stream()
|
||||||
|
.anyMatch(key -> key.getDisplayName().equals(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T doSynchronized(String user, boolean write, Supplier<T> callback) {
|
||||||
|
final ReadWriteLock lockFactory = locks.get(user);
|
||||||
|
Lock lock = write ? lockFactory.writeLock() : lockFactory.readLock();
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
return callback.get();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void cleanupForDeletedUser(UserEvent userEvent) {
|
||||||
|
if (userEvent.getEventType() == HandlerEventType.DELETE) {
|
||||||
|
store.remove(userEvent.getItem().getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class CreationResult {
|
||||||
|
private final String token;
|
||||||
|
private final String id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class CheckResult {
|
||||||
|
private final String user;
|
||||||
|
private final String permissionRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.jsonwebtoken.io.Decoder;
|
||||||
|
import io.jsonwebtoken.io.Decoders;
|
||||||
|
import io.jsonwebtoken.io.DecodingException;
|
||||||
|
import io.jsonwebtoken.io.Encoder;
|
||||||
|
import io.jsonwebtoken.io.Encoders;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static java.util.Optional.empty;
|
||||||
|
import static java.util.Optional.of;
|
||||||
|
|
||||||
|
class ApiKeyTokenHandler {
|
||||||
|
|
||||||
|
private static final Encoder<byte[], String> encoder = Encoders.BASE64URL;
|
||||||
|
private static final Decoder<String, byte[]> decoder = Decoders.BASE64URL;
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ApiKeyTokenHandler.class);
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
String createToken(String user, ApiKey apiKey, String passphrase) {
|
||||||
|
final Token token = new Token(apiKey.getId(), user, passphrase);
|
||||||
|
try {
|
||||||
|
return encoder.encode(OBJECT_MAPPER.writeValueAsBytes(token));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
LOG.error("could not serialize token");
|
||||||
|
throw new TokenSerializationException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Token> readToken(String token) {
|
||||||
|
try {
|
||||||
|
return of(OBJECT_MAPPER.readValue(decoder.decode(token), Token.class));
|
||||||
|
} catch (IOException | DecodingException e) {
|
||||||
|
LOG.warn("error reading api token", e);
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public static class Token {
|
||||||
|
private final String apiKeyId;
|
||||||
|
private final String user;
|
||||||
|
private final String passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TokenSerializationException extends RuntimeException {
|
||||||
|
public TokenSerializationException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import sonia.scm.xml.XmlInstantAdapter;
|
||||||
|
|
||||||
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
import javax.xml.bind.annotation.XmlElement;
|
||||||
|
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
|
@Getter
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
class ApiKeyWithPassphrase {
|
||||||
|
private String id;
|
||||||
|
@XmlElement(name = "display-name")
|
||||||
|
private String displayName;
|
||||||
|
@XmlElement(name = "permission-role")
|
||||||
|
private String permissionRole;
|
||||||
|
private String passphrase;
|
||||||
|
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
|
||||||
|
private Instant created;
|
||||||
|
}
|
||||||
@@ -250,6 +250,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
|
|||||||
builder.add(getUserAutocompletePermission());
|
builder.add(getUserAutocompletePermission());
|
||||||
builder.add(getGroupAutocompletePermission());
|
builder.add(getGroupAutocompletePermission());
|
||||||
builder.add(getChangeOwnPasswordPermission(user));
|
builder.add(getChangeOwnPasswordPermission(user));
|
||||||
|
builder.add(getApiKeyPermission(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER));
|
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER));
|
||||||
@@ -266,6 +267,10 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
|
|||||||
return UserPermissions.changePassword(user).asShiroString();
|
return UserPermissions.changePassword(user).asShiroString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getApiKeyPermission(User user) {
|
||||||
|
return UserPermissions.changeApiKeys(user).asShiroString();
|
||||||
|
}
|
||||||
|
|
||||||
private String getUserAutocompletePermission() {
|
private String getUserAutocompletePermission() {
|
||||||
return UserPermissions.autocomplete().asShiroString();
|
return UserPermissions.autocomplete().asShiroString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.security;
|
package sonia.scm.security;
|
||||||
|
|
||||||
//~--- non-JDK imports --------------------------------------------------------
|
//~--- non-JDK imports --------------------------------------------------------
|
||||||
@@ -57,9 +57,9 @@ import java.util.Set;
|
|||||||
@Singleton
|
@Singleton
|
||||||
public class DefaultRealm extends AuthorizingRealm
|
public class DefaultRealm extends AuthorizingRealm
|
||||||
{
|
{
|
||||||
|
|
||||||
private static final String SEPARATOR = System.getProperty("line.separator", "\n");
|
private static final String SEPARATOR = System.getProperty("line.separator", "\n");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the logger for DefaultRealm
|
* the logger for DefaultRealm
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +68,7 @@ public class DefaultRealm extends AuthorizingRealm
|
|||||||
/** Field description */
|
/** Field description */
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final String REALM = "DefaultRealm";
|
static final String REALM = "DefaultRealm";
|
||||||
|
private final ScmPermissionResolver permissionResolver;
|
||||||
|
|
||||||
//~--- constructors ---------------------------------------------------------
|
//~--- constructors ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -90,11 +91,18 @@ public class DefaultRealm extends AuthorizingRealm
|
|||||||
matcher.setPasswordService(service);
|
matcher.setPasswordService(service);
|
||||||
setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher));
|
setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher));
|
||||||
setAuthenticationTokenClass(UsernamePasswordToken.class);
|
setAuthenticationTokenClass(UsernamePasswordToken.class);
|
||||||
|
permissionResolver = new ScmPermissionResolver();
|
||||||
|
setPermissionResolver(permissionResolver);
|
||||||
|
|
||||||
// we cache in the AuthorizationCollector
|
// we cache in the AuthorizationCollector
|
||||||
setCachingEnabled(false);
|
setCachingEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScmPermissionResolver getPermissionResolver() {
|
||||||
|
return permissionResolver;
|
||||||
|
}
|
||||||
|
|
||||||
//~--- methods --------------------------------------------------------------
|
//~--- methods --------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,13 +176,13 @@ public class DefaultRealm extends AuthorizingRealm
|
|||||||
|
|
||||||
private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) {
|
private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) {
|
||||||
StringBuilder buffer = new StringBuilder("authorization summary: ");
|
StringBuilder buffer = new StringBuilder("authorization summary: ");
|
||||||
|
|
||||||
buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal());
|
buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal());
|
||||||
buffer.append(SEPARATOR).append("roles : ");
|
buffer.append(SEPARATOR).append("roles : ");
|
||||||
append(buffer, original.getRoles());
|
append(buffer, original.getRoles());
|
||||||
buffer.append(SEPARATOR).append("scope : ");
|
buffer.append(SEPARATOR).append("scope : ");
|
||||||
append(buffer, collection.oneByType(Scope.class));
|
append(buffer, collection.oneByType(Scope.class));
|
||||||
|
|
||||||
if ( filtered != null ) {
|
if ( filtered != null ) {
|
||||||
buffer.append(SEPARATOR).append("permissions (filtered by scope): ");
|
buffer.append(SEPARATOR).append("permissions (filtered by scope): ");
|
||||||
append(buffer, filtered);
|
append(buffer, filtered);
|
||||||
@@ -183,21 +191,21 @@ public class DefaultRealm extends AuthorizingRealm
|
|||||||
buffer.append(SEPARATOR).append("permissions: ");
|
buffer.append(SEPARATOR).append("permissions: ");
|
||||||
}
|
}
|
||||||
append(buffer, original);
|
append(buffer, original);
|
||||||
|
|
||||||
LOG.trace(buffer.toString());
|
LOG.trace(buffer.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void append(StringBuilder buffer, AuthorizationInfo authz) {
|
private void append(StringBuilder buffer, AuthorizationInfo authz) {
|
||||||
append(buffer, authz.getStringPermissions());
|
append(buffer, authz.getStringPermissions());
|
||||||
append(buffer, authz.getObjectPermissions());
|
append(buffer, authz.getObjectPermissions());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void append(StringBuilder buffer, Iterable<?> iterable){
|
private void append(StringBuilder buffer, Iterable<?> iterable){
|
||||||
if (iterable != null){
|
if (iterable != null){
|
||||||
for ( Object item : iterable )
|
for ( Object item : iterable )
|
||||||
{
|
{
|
||||||
buffer.append(SEPARATOR).append(" - ").append(item);
|
buffer.append(SEPARATOR).append(" - ").append(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import org.apache.shiro.authz.permission.PermissionResolver;
|
||||||
|
|
||||||
|
public class ScmPermissionResolver implements PermissionResolver {
|
||||||
|
@Override
|
||||||
|
public ScmWildcardPermission resolvePermission(String permissionString) {
|
||||||
|
return new ScmWildcardPermission(permissionString);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import org.apache.commons.collections.CollectionUtils;
|
||||||
|
import org.apache.shiro.authz.permission.WildcardPermission;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static java.util.Collections.singleton;
|
||||||
|
import static java.util.Optional.empty;
|
||||||
|
import static java.util.Optional.of;
|
||||||
|
|
||||||
|
public class ScmWildcardPermission extends WildcardPermission {
|
||||||
|
public ScmWildcardPermission(String permissionString) {
|
||||||
|
super(permissionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits this permission to the given scope. This will result in a collection of new permissions. This
|
||||||
|
* collection can be empty (but this will not return <code>null</code>). Three examples:
|
||||||
|
* <table>
|
||||||
|
* <tr>
|
||||||
|
* <th>This permission</th>
|
||||||
|
* <th>Scope</th>
|
||||||
|
* <th>Resulting permission(s)</th>
|
||||||
|
* </tr>
|
||||||
|
* <tr>
|
||||||
|
* <td><code>repository:*:42</code></td>
|
||||||
|
* <td><code>repository:read,pull:*</code></td>
|
||||||
|
* <td><code>repository:read,pull:42</code></td>
|
||||||
|
* </tr>
|
||||||
|
* <tr>
|
||||||
|
* <td><code>repository:read:*</code></td>
|
||||||
|
* <td><code>repository:*:42</code>, <code>repository:*:1337</code></td>
|
||||||
|
* <td><code>repository:read:42</code>, <code>repository:read:1337</code></td>
|
||||||
|
* </tr>
|
||||||
|
* <tr>
|
||||||
|
* <td><code>user:*:*</code></td>
|
||||||
|
* <td><code>repository:read,pull:*</code></td>
|
||||||
|
* <td><i>empty</i></td>
|
||||||
|
* </tr>
|
||||||
|
* </table>
|
||||||
|
* @param scope The scope this permission should be limited to.
|
||||||
|
* @return A collection with the resulting permissions (mind that this can be empty, but not <code>null</code>).
|
||||||
|
*/
|
||||||
|
Collection<ScmWildcardPermission> limit(Scope scope) {
|
||||||
|
Collection<ScmWildcardPermission> result = new ArrayList<>();
|
||||||
|
for (String s : scope) {
|
||||||
|
limit(s).ifPresent(result::add);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits this permission to a scope with a single permission. For examples see {@link #limit(String)}.
|
||||||
|
* @param scope The single scope.
|
||||||
|
* @return An {@link Optional} with the resulting permission if there was a overlap between this and the scope, or
|
||||||
|
* an empty {@link Optional} otherwise.
|
||||||
|
*/
|
||||||
|
Optional<ScmWildcardPermission> limit(String scope) {
|
||||||
|
return limit(new ScmWildcardPermission(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits this permission to a scope with a single permission. For examples see {@link #limit(String)}.
|
||||||
|
* @param scope The single scope.
|
||||||
|
* @return An {@link Optional} with the resulting permission if there was a overlap between this and the scope, or
|
||||||
|
* an empty {@link Optional} otherwise.
|
||||||
|
*/
|
||||||
|
Optional<ScmWildcardPermission> limit(ScmWildcardPermission scope) {
|
||||||
|
// if one permission is a subset of the other, we can return the smaller one.
|
||||||
|
if (this.implies(scope)) {
|
||||||
|
return of(scope);
|
||||||
|
}
|
||||||
|
if (scope.implies(this)) {
|
||||||
|
return of(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First we check, whether the subjects are the same. We do not use permissions with different subjects, so we
|
||||||
|
// either have both this the same subject, or we have no overlap.
|
||||||
|
final List<Set<String>> theseParts = getParts();
|
||||||
|
final List<Set<String>> scopeParts = scope.getParts();
|
||||||
|
|
||||||
|
if (!getEntries(theseParts, 0).equals(getEntries(scopeParts, 0))) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String subject = getEntries(scopeParts, 0).iterator().next();
|
||||||
|
|
||||||
|
// Now we create the intersections of verbs and ids to create the resulting permission
|
||||||
|
// (if not one of the resulting sets is empty)
|
||||||
|
Collection<String> verbs = intersect(theseParts, scopeParts, 1);
|
||||||
|
Collection<String> ids = intersect(theseParts, scopeParts, 2);
|
||||||
|
|
||||||
|
if (verbs.isEmpty() || ids.isEmpty()) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(new ScmWildcardPermission(subject + ":" + String.join(",", verbs) + ":" + String.join(",", ids)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Collection<String> intersect(List<Set<String>> theseParts, List<Set<String>> scopeParts, int position) {
|
||||||
|
final Set<String> theseEntries = getEntries(theseParts, position);
|
||||||
|
final Set<String> scopeEntries = getEntries(scopeParts, position);
|
||||||
|
if (isWildcard(theseEntries)) {
|
||||||
|
return scopeEntries;
|
||||||
|
}
|
||||||
|
if (isWildcard(scopeEntries)) {
|
||||||
|
return theseEntries;
|
||||||
|
}
|
||||||
|
return CollectionUtils.intersection(theseEntries, scopeEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles "shortened" permissions like <code>repository:read</code> that should be <code>repository:read:*</code>.
|
||||||
|
*/
|
||||||
|
private Set<String> getEntries(List<Set<String>> theseParts, int position) {
|
||||||
|
if (position >= theseParts.size()) {
|
||||||
|
return singleton(WILDCARD_TOKEN);
|
||||||
|
}
|
||||||
|
return theseParts.get(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isWildcard(Set<String> entries) {
|
||||||
|
return entries.size() == 1 && entries.contains(WILDCARD_TOKEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,28 +21,26 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.security;
|
package sonia.scm.security;
|
||||||
|
|
||||||
import com.google.common.collect.Collections2;
|
import com.google.common.collect.Collections2;
|
||||||
import com.google.common.collect.ImmutableList;
|
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
|
import org.apache.shiro.authz.AuthorizationInfo;
|
||||||
|
import org.apache.shiro.authz.Permission;
|
||||||
|
import org.apache.shiro.authz.SimpleAuthorizationInfo;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Predicate;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import org.apache.shiro.authz.AuthorizationInfo;
|
|
||||||
import org.apache.shiro.authz.Permission;
|
|
||||||
import org.apache.shiro.authz.SimpleAuthorizationInfo;
|
|
||||||
import org.apache.shiro.authz.permission.PermissionResolver;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Util methods for {@link Scope}.
|
* Util methods for {@link Scope}.
|
||||||
*
|
*
|
||||||
* @author Sebastian Sdorra
|
* @author Sebastian Sdorra
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
@@ -50,16 +48,16 @@ public final class Scopes {
|
|||||||
|
|
||||||
/** Key of scope in the claims of a token **/
|
/** Key of scope in the claims of a token **/
|
||||||
public final static String CLAIMS_KEY = "scope";
|
public final static String CLAIMS_KEY = "scope";
|
||||||
|
|
||||||
private Scopes() {
|
private Scopes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns scope from a token claims. If the claims does not contain a scope object, the method will return an empty
|
* Returns scope from a token claims. If the claims does not contain a scope object, the method will return an empty
|
||||||
* scope.
|
* scope.
|
||||||
*
|
*
|
||||||
* @param claims token claims
|
* @param claims token claims
|
||||||
*
|
*
|
||||||
* @return scope of claims
|
* @return scope of claims
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -70,11 +68,11 @@ public final class Scopes {
|
|||||||
}
|
}
|
||||||
return scope;
|
return scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not
|
* Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not
|
||||||
* empty.
|
* empty.
|
||||||
*
|
*
|
||||||
* @param claims token claims
|
* @param claims token claims
|
||||||
* @param scope scope
|
* @param scope scope
|
||||||
*/
|
*/
|
||||||
@@ -83,55 +81,51 @@ public final class Scopes {
|
|||||||
claims.put(CLAIMS_KEY, ImmutableSet.copyOf(scope));
|
claims.put(CLAIMS_KEY, ImmutableSet.copyOf(scope));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter permissions from {@link AuthorizationInfo} by scope values. Only permission definitions from the scope will
|
* Limit permissions from {@link AuthorizationInfo} by scope values. Permission definitions from the
|
||||||
* be returned and only if a permission from the {@link AuthorizationInfo} implies the requested scope permission.
|
* {@link AuthorizationInfo} will be returned, if a permission from the scope implies the original permission.
|
||||||
*
|
* If a permission from the {@link AuthorizationInfo} exceeds the permissions defined by the scope, it will
|
||||||
|
* be reduced. If the latter computation results in an empty permission, it will be omitted.
|
||||||
|
*
|
||||||
* @param resolver permission resolver
|
* @param resolver permission resolver
|
||||||
* @param authz authorization info
|
* @param authz authorization info
|
||||||
* @param scope scope
|
* @param scope scope
|
||||||
*
|
*
|
||||||
* @return filtered {@link AuthorizationInfo}
|
* @return limited {@link AuthorizationInfo}
|
||||||
*/
|
*/
|
||||||
public static AuthorizationInfo filter(PermissionResolver resolver, AuthorizationInfo authz, Scope scope) {
|
public static AuthorizationInfo filter(ScmPermissionResolver resolver, AuthorizationInfo authz, Scope scope) {
|
||||||
List<Permission> authzPermissions = authzPermissions(resolver, authz);
|
List<Permission> authzPermissions = authzPermissions(resolver, authz);
|
||||||
Predicate<Permission> predicate = implies(authzPermissions);
|
Set<Permission> filteredPermissions = authzPermissions
|
||||||
Set<Permission> filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope))
|
|
||||||
.stream()
|
.stream()
|
||||||
.filter(predicate)
|
.map(p -> asScmWildcardPermission(p))
|
||||||
|
.map(p -> p.limit(scope))
|
||||||
|
.flatMap(Collection::stream)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
Set<String> roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles()));
|
Set<String> roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles()));
|
||||||
SimpleAuthorizationInfo authzFiltered = new SimpleAuthorizationInfo(roles);
|
SimpleAuthorizationInfo authzFiltered = new SimpleAuthorizationInfo(roles);
|
||||||
authzFiltered.setObjectPermissions(filteredPermissions);
|
authzFiltered.setObjectPermissions(filteredPermissions);
|
||||||
return authzFiltered;
|
return authzFiltered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ScmWildcardPermission asScmWildcardPermission(Permission p) {
|
||||||
|
return p instanceof ScmWildcardPermission ? (ScmWildcardPermission) p : new ScmWildcardPermission(p.toString());
|
||||||
|
}
|
||||||
|
|
||||||
private static <T> Collection<T> nullToEmpty(Collection<T> collection) {
|
private static <T> Collection<T> nullToEmpty(Collection<T> collection) {
|
||||||
return collection != null ? collection : Collections.emptySet();
|
return collection != null ? collection : Collections.emptySet();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Collection<Permission> resolve(PermissionResolver resolver, Collection<String> permissions) {
|
private static Collection<ScmWildcardPermission> resolve(ScmPermissionResolver resolver, Collection<String> permissions) {
|
||||||
return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission);
|
return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Predicate<Permission> implies(Iterable<Permission> authzPermissions){
|
private static List<Permission> authzPermissions(ScmPermissionResolver resolver, AuthorizationInfo authz){
|
||||||
return (scopePermission) -> {
|
|
||||||
for ( Permission authzPermission : authzPermissions ) {
|
|
||||||
if (authzPermission.implies(scopePermission)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Permission> authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){
|
|
||||||
List<Permission> authzPermissions = Lists.newArrayList();
|
List<Permission> authzPermissions = Lists.newArrayList();
|
||||||
authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions()));
|
authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions()));
|
||||||
authzPermissions.addAll(resolve(resolver, authz.getStringPermissions()));
|
authzPermissions.addAll(resolve(resolver, authz.getStringPermissions()));
|
||||||
return authzPermissions;
|
return authzPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import com.github.sdorra.shiro.ShiroRule;
|
import com.github.sdorra.shiro.ShiroRule;
|
||||||
@@ -41,6 +41,8 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import sonia.scm.ContextEntry;
|
import sonia.scm.ContextEntry;
|
||||||
import sonia.scm.group.GroupCollector;
|
import sonia.scm.group.GroupCollector;
|
||||||
|
import sonia.scm.security.ApiKey;
|
||||||
|
import sonia.scm.security.ApiKeyService;
|
||||||
import sonia.scm.user.InvalidPasswordException;
|
import sonia.scm.user.InvalidPasswordException;
|
||||||
import sonia.scm.user.User;
|
import sonia.scm.user.User;
|
||||||
import sonia.scm.user.UserManager;
|
import sonia.scm.user.UserManager;
|
||||||
@@ -52,13 +54,17 @@ import java.io.UnsupportedEncodingException;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
|
||||||
|
import static com.google.inject.util.Providers.of;
|
||||||
|
import static java.time.Instant.now;
|
||||||
|
import static java.util.Arrays.asList;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertTrue;
|
|
||||||
import static org.mockito.Mockito.any;
|
import static org.mockito.Mockito.any;
|
||||||
import static org.mockito.Mockito.doNothing;
|
import static org.mockito.Mockito.doNothing;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.spy;
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.mockito.MockitoAnnotations.initMocks;
|
import static org.mockito.MockitoAnnotations.initMocks;
|
||||||
|
|
||||||
@@ -87,8 +93,13 @@ public class MeResourceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private UserManager userManager;
|
private UserManager userManager;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApiKeyService apiKeyService;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private MeDtoFactory meDtoFactory;
|
private MeDtoFactory meDtoFactory;
|
||||||
|
@InjectMocks
|
||||||
|
private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper;
|
||||||
|
|
||||||
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
private ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||||
|
|
||||||
@@ -96,6 +107,8 @@ public class MeResourceTest {
|
|||||||
private PasswordService passwordService;
|
private PasswordService passwordService;
|
||||||
private User originalUser;
|
private User originalUser;
|
||||||
|
|
||||||
|
private MockHttpResponse response = new MockHttpResponse();
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void prepareEnvironment() {
|
public void prepareEnvironment() {
|
||||||
initMocks(this);
|
initMocks(this);
|
||||||
@@ -106,7 +119,9 @@ public class MeResourceTest {
|
|||||||
when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2"));
|
when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2"));
|
||||||
when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod();
|
when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod();
|
||||||
when(userManager.getDefaultType()).thenReturn("xml");
|
when(userManager.getDefaultType()).thenReturn("xml");
|
||||||
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService);
|
ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
|
||||||
|
ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks);
|
||||||
|
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource));
|
||||||
when(uriInfo.getApiRestUri()).thenReturn(URI.create("/"));
|
when(uriInfo.getApiRestUri()).thenReturn(URI.create("/"));
|
||||||
when(scmPathInfoStore.get()).thenReturn(uriInfo);
|
when(scmPathInfoStore.get()).thenReturn(uriInfo);
|
||||||
dispatcher.addSingletonResource(meResource);
|
dispatcher.addSingletonResource(meResource);
|
||||||
@@ -118,14 +133,14 @@ public class MeResourceTest {
|
|||||||
|
|
||||||
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2);
|
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2);
|
||||||
request.accept(VndMediaType.ME);
|
request.accept(VndMediaType.ME);
|
||||||
MockHttpResponse response = new MockHttpResponse();
|
|
||||||
|
|
||||||
dispatcher.invoke(request, response);
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
||||||
assertTrue(response.getContentAsString().contains("\"name\":\"trillian\""));
|
assertThat(response.getContentAsString()).contains("\"name\":\"trillian\"");
|
||||||
assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/me/\"}"));
|
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/\"}");
|
||||||
assertTrue(response.getContentAsString().contains("\"delete\":{\"href\":\"/v2/users/trillian\"}"));
|
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian\"}");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/me/api_keys\"}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyUserToSubject(User user) {
|
private void applyUserToSubject(User user) {
|
||||||
@@ -149,7 +164,6 @@ public class MeResourceTest {
|
|||||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||||
.content(content.getBytes());
|
.content(content.getBytes());
|
||||||
MockHttpResponse response = new MockHttpResponse();
|
|
||||||
|
|
||||||
when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword);
|
when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword);
|
||||||
when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword);
|
when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword);
|
||||||
@@ -174,7 +188,6 @@ public class MeResourceTest {
|
|||||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||||
.content(content.getBytes());
|
.content(content.getBytes());
|
||||||
MockHttpResponse response = new MockHttpResponse();
|
|
||||||
|
|
||||||
dispatcher.invoke(request, response);
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
@@ -190,7 +203,6 @@ public class MeResourceTest {
|
|||||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||||
.content(content.getBytes());
|
.content(content.getBytes());
|
||||||
MockHttpResponse response = new MockHttpResponse();
|
|
||||||
|
|
||||||
dispatcher.invoke(request, response);
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
@@ -206,7 +218,6 @@ public class MeResourceTest {
|
|||||||
.put("/" + MeResource.ME_PATH_V2 + "password")
|
.put("/" + MeResource.ME_PATH_V2 + "password")
|
||||||
.contentType(VndMediaType.PASSWORD_CHANGE)
|
.contentType(VndMediaType.PASSWORD_CHANGE)
|
||||||
.content(content.getBytes());
|
.content(content.getBytes());
|
||||||
MockHttpResponse response = new MockHttpResponse();
|
|
||||||
|
|
||||||
doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-")))
|
doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-")))
|
||||||
.when(userManager).changePasswordForLoggedInUser(any(), any());
|
.when(userManager).changePasswordForLoggedInUser(any(), any());
|
||||||
@@ -216,6 +227,80 @@ public class MeResourceTest {
|
|||||||
assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus());
|
assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException {
|
||||||
|
when(apiKeyService.getKeys())
|
||||||
|
.thenReturn(asList(
|
||||||
|
new ApiKey("1", "key 1", "READ", now()),
|
||||||
|
new ApiKey("2", "key 2", "WRITE", now())));
|
||||||
|
|
||||||
|
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "api_keys");
|
||||||
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
|
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
||||||
|
|
||||||
|
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"permissionRole\":\"READ\"");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"permissionRole\":\"WRITE\"");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys\"}");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/me/api_keys\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||||
|
when(apiKeyService.getKeys())
|
||||||
|
.thenReturn(asList(
|
||||||
|
new ApiKey("1", "key 1", "READ", now()),
|
||||||
|
new ApiKey("2", "key 2", "WRITE", now())));
|
||||||
|
|
||||||
|
MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "api_keys/1");
|
||||||
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
|
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
|
||||||
|
|
||||||
|
assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\"");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"permissionRole\":\"READ\"");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys/1\"}");
|
||||||
|
assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/me/api_keys/1\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||||
|
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||||
|
|
||||||
|
final MockHttpRequest request = MockHttpRequest
|
||||||
|
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
|
||||||
|
.contentType(VndMediaType.API_KEY)
|
||||||
|
.content("{\"displayName\":\"guide\",\"permissionRole\":\"READ\"}".getBytes());
|
||||||
|
|
||||||
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(201);
|
||||||
|
assertThat(response.getContentAsString()).isEqualTo("abc");
|
||||||
|
assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/me/api_keys/1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException {
|
||||||
|
when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1"));
|
||||||
|
|
||||||
|
final MockHttpRequest request = MockHttpRequest
|
||||||
|
.post("/" + MeResource.ME_PATH_V2 + "api_keys/")
|
||||||
|
.contentType(VndMediaType.API_KEY)
|
||||||
|
.content("{\"displayName\":\"guide\",\"pemissionRole\":\"\"}".getBytes());
|
||||||
|
|
||||||
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldDeleteExistingApiKey() throws URISyntaxException {
|
||||||
|
MockHttpRequest request = MockHttpRequest.delete("/" + MeResource.ME_PATH_V2 + "api_keys/1");
|
||||||
|
dispatcher.invoke(request, response);
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(204);
|
||||||
|
verify(apiKeyService).remove("1");
|
||||||
|
}
|
||||||
|
|
||||||
private User createDummyUser(String name) {
|
private User createDummyUser(String name) {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ public class ResourceLinksMock {
|
|||||||
lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo));
|
lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo));
|
||||||
lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo));
|
lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo));
|
||||||
lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo));
|
lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo));
|
||||||
|
lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo));
|
||||||
|
lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo));
|
||||||
|
|
||||||
return resourceLinks;
|
return resourceLinks;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import com.github.sdorra.shiro.ShiroRule;
|
import com.github.sdorra.shiro.ShiroRule;
|
||||||
|
|||||||
105
scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java
Normal file
105
scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import org.apache.shiro.authc.AuthenticationToken;
|
||||||
|
import org.apache.shiro.authz.AuthorizationException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Answers;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import sonia.scm.repository.RepositoryRole;
|
||||||
|
import sonia.scm.repository.RepositoryRoleManager;
|
||||||
|
|
||||||
|
import static java.util.Collections.singleton;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static sonia.scm.security.BearerToken.valueOf;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ApiKeyRealmTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
ApiKeyService apiKeyService;
|
||||||
|
@Mock
|
||||||
|
DAORealmHelperFactory helperFactory;
|
||||||
|
@Mock
|
||||||
|
DAORealmHelper helper;
|
||||||
|
@Mock(answer = Answers.RETURNS_SELF)
|
||||||
|
DAORealmHelper.AuthenticationInfoBuilder authenticationInfoBuilder;
|
||||||
|
@Mock
|
||||||
|
RepositoryRoleManager repositoryRoleManager;
|
||||||
|
|
||||||
|
ApiKeyRealm realm;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void initRealmHelper() {
|
||||||
|
lenient().when(helperFactory.create("ApiTokenRealm")).thenReturn(helper);
|
||||||
|
lenient().when(helper.authenticationInfoBuilder(any())).thenReturn(authenticationInfoBuilder);
|
||||||
|
realm = new ApiKeyRealm(apiKeyService, helperFactory, repositoryRoleManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateAuthenticationWithScope() {
|
||||||
|
when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ"));
|
||||||
|
when(repositoryRoleManager.get("READ")).thenReturn(new RepositoryRole("guide", singleton("read"), "system"));
|
||||||
|
|
||||||
|
realm.doGetAuthenticationInfo(valueOf("towel"));
|
||||||
|
|
||||||
|
verify(helper).authenticationInfoBuilder("ford");
|
||||||
|
verifyScopeSet("repository:read:*");
|
||||||
|
verify(authenticationInfoBuilder).withSessionId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailWithoutBearerToken() {
|
||||||
|
AuthenticationToken otherToken = mock(AuthenticationToken.class);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(otherToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailWithUnknownRole() {
|
||||||
|
when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ"));
|
||||||
|
when(repositoryRoleManager.get("READ")).thenReturn(null);
|
||||||
|
|
||||||
|
BearerToken token = valueOf("towel");
|
||||||
|
assertThrows(AuthorizationException.class, () -> realm.doGetAuthenticationInfo(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
void verifyScopeSet(String... permissions) {
|
||||||
|
verify(authenticationInfoBuilder).withScope(argThat(scope -> {
|
||||||
|
assertThat(scope).containsExactly(permissions);
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import org.apache.shiro.authc.credential.PasswordService;
|
||||||
|
import org.apache.shiro.authz.AuthorizationException;
|
||||||
|
import org.apache.shiro.subject.PrincipalCollection;
|
||||||
|
import org.apache.shiro.subject.Subject;
|
||||||
|
import org.apache.shiro.util.ThreadContext;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import sonia.scm.AlreadyExistsException;
|
||||||
|
import sonia.scm.HandlerEventType;
|
||||||
|
import sonia.scm.store.DataStore;
|
||||||
|
import sonia.scm.store.DataStoreFactory;
|
||||||
|
import sonia.scm.store.InMemoryDataStore;
|
||||||
|
import sonia.scm.store.InMemoryDataStoreFactory;
|
||||||
|
import sonia.scm.user.User;
|
||||||
|
import sonia.scm.user.UserEvent;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ApiKeyServiceTest {
|
||||||
|
|
||||||
|
int nextKey = 1;
|
||||||
|
int nextId = 1;
|
||||||
|
|
||||||
|
PasswordService passwordService = mock(PasswordService.class);
|
||||||
|
Supplier<String> passphraseGenerator = () -> Integer.toString(nextKey++);
|
||||||
|
KeyGenerator keyGenerator = () -> Integer.toString(nextId++);
|
||||||
|
ApiKeyTokenHandler tokenHandler = new ApiKeyTokenHandler();
|
||||||
|
DataStoreFactory storeFactory = new InMemoryDataStoreFactory(new InMemoryDataStore<ApiKeyCollection>());
|
||||||
|
DataStore<ApiKeyCollection> store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build();
|
||||||
|
ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator, tokenHandler, passphraseGenerator);
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void mockPasswordService() {
|
||||||
|
when(passwordService.encryptPassword(any()))
|
||||||
|
.thenAnswer(invocationOnMock -> invocationOnMock.getArgument(0) + "-hashed");
|
||||||
|
when(passwordService.passwordsMatch(any(), any()))
|
||||||
|
.thenAnswer(invocationOnMock -> invocationOnMock.getArgument(1, String.class).startsWith(invocationOnMock.getArgument(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class WithLoggedInUser {
|
||||||
|
@BeforeEach
|
||||||
|
void mockUser() {
|
||||||
|
final Subject subject = mock(Subject.class);
|
||||||
|
ThreadContext.bind(subject);
|
||||||
|
final PrincipalCollection principalCollection = mock(PrincipalCollection.class);
|
||||||
|
when(subject.getPrincipals()).thenReturn(principalCollection);
|
||||||
|
when(principalCollection.getPrimaryPrincipal()).thenReturn("dent");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void unbindSubject() {
|
||||||
|
ThreadContext.unbindSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateNewKeyAndStoreItHashed() {
|
||||||
|
service.createNewKey("1", "READ");
|
||||||
|
|
||||||
|
ApiKeyCollection apiKeys = store.get("dent");
|
||||||
|
|
||||||
|
assertThat(apiKeys.getKeys()).hasSize(1);
|
||||||
|
ApiKeyWithPassphrase key = apiKeys.getKeys().iterator().next();
|
||||||
|
assertThat(key.getPermissionRole()).isEqualTo("READ");
|
||||||
|
assertThat(key.getPassphrase()).isEqualTo("1-hashed");
|
||||||
|
|
||||||
|
ApiKeyService.CheckResult role = service.check("dent", "1", "1-hashed");
|
||||||
|
|
||||||
|
assertThat(role).extracting("permissionRole").isEqualTo("READ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnRoleForKey() {
|
||||||
|
String newKey = service.createNewKey("1", "READ").getToken();
|
||||||
|
|
||||||
|
ApiKeyService.CheckResult role = service.check(newKey);
|
||||||
|
|
||||||
|
assertThat(role).extracting("permissionRole").isEqualTo("READ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleNewUser() {
|
||||||
|
assertThat(service.getKeys()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotReturnAnythingWithWrongKey() {
|
||||||
|
service.createNewKey("1", "READ");
|
||||||
|
|
||||||
|
assertThrows(AuthorizationException.class, () -> service.check("dent", "1", "wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldAddSecondKey() {
|
||||||
|
ApiKeyService.CreationResult firstKey = service.createNewKey("1", "READ");
|
||||||
|
ApiKeyService.CreationResult secondKey = service.createNewKey("2", "WRITE");
|
||||||
|
|
||||||
|
ApiKeyCollection apiKeys = store.get("dent");
|
||||||
|
|
||||||
|
assertThat(apiKeys.getKeys()).hasSize(2);
|
||||||
|
|
||||||
|
assertThat(service.check(firstKey.getToken())).extracting("permissionRole").isEqualTo("READ");
|
||||||
|
assertThat(service.check(secondKey.getToken())).extracting("permissionRole").isEqualTo("WRITE");
|
||||||
|
|
||||||
|
assertThat(service.getKeys()).extracting("id")
|
||||||
|
.contains(firstKey.getId(), secondKey.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRemoveKey() {
|
||||||
|
String firstKey = service.createNewKey("first", "READ").getToken();
|
||||||
|
String secondKey = service.createNewKey("second", "WRITE").getToken();
|
||||||
|
|
||||||
|
service.remove("1");
|
||||||
|
|
||||||
|
assertThrows(AuthorizationException.class, () -> service.check(firstKey));
|
||||||
|
assertThat(service.check(secondKey)).extracting("permissionRole").isEqualTo("WRITE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailWhenAddingSameNameTwice() {
|
||||||
|
String firstKey = service.createNewKey("1", "READ").getToken();
|
||||||
|
|
||||||
|
assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE"));
|
||||||
|
|
||||||
|
assertThat(service.check(firstKey)).extracting("permissionRole").isEqualTo("READ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldIgnoreCorrectPassphraseWithWrongName() {
|
||||||
|
String firstKey = service.createNewKey("1", "READ").getToken();
|
||||||
|
|
||||||
|
assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDeleteTokensWhenUserIsDeleted() {
|
||||||
|
service.createNewKey("1", "READ").getToken();
|
||||||
|
|
||||||
|
assertThat(store.get("dent").getKeys()).hasSize(1);
|
||||||
|
|
||||||
|
service.cleanupForDeletedUser(new UserEvent(HandlerEventType.DELETE, new User("dent")));
|
||||||
|
|
||||||
|
assertThat(store.get("dent")).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.io.Encoders;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static java.time.Instant.now;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ApiKeyTokenHandlerTest {
|
||||||
|
|
||||||
|
ApiKeyTokenHandler handler = new ApiKeyTokenHandler();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSerializeAndDeserializeToken() {
|
||||||
|
String tokenString = handler.createToken("dent", new ApiKey("42", "hg2g", "READ", now()), "some secret");
|
||||||
|
|
||||||
|
Optional<ApiKeyTokenHandler.Token> token = handler.readToken(tokenString);
|
||||||
|
|
||||||
|
assertThat(token).isNotEmpty();
|
||||||
|
assertThat(token).get().extracting("user").isEqualTo("dent");
|
||||||
|
assertThat(token).get().extracting("apiKeyId").isEqualTo("42");
|
||||||
|
assertThat(token).get().extracting("passphrase").isEqualTo("some secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotFailWithInvalidTokenEncoding() {
|
||||||
|
Optional<ApiKeyTokenHandler.Token> token = handler.readToken("invalid token");
|
||||||
|
|
||||||
|
assertThat(token).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotFailWithInvalidTokenContent() {
|
||||||
|
Optional<ApiKeyTokenHandler.Token> token = handler.readToken(Encoders.BASE64URL.encode("{\"invalid\":\"token\"}".getBytes()));
|
||||||
|
|
||||||
|
assertThat(token).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,8 +167,8 @@ public class DefaultAuthorizationCollectorTest {
|
|||||||
|
|
||||||
AuthorizationInfo authInfo = collector.collect();
|
AuthorizationInfo authInfo = collector.collect();
|
||||||
assertThat(authInfo.getRoles(), Matchers.contains(Role.USER));
|
assertThat(authInfo.getRoles(), Matchers.contains(Role.USER));
|
||||||
assertThat(authInfo.getStringPermissions(), hasSize(4));
|
assertThat(authInfo.getStringPermissions(), hasSize(5));
|
||||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian"));
|
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian", "user:changeApiKeys:trillian"));
|
||||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ public class DefaultAuthorizationCollectorTest {
|
|||||||
AuthorizationInfo authInfo = collector.collect();
|
AuthorizationInfo authInfo = collector.collect();
|
||||||
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
||||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian"));
|
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian", "user:changeApiKeys:trillian"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,7 +244,7 @@ public class DefaultAuthorizationCollectorTest {
|
|||||||
AuthorizationInfo authInfo = collector.collect();
|
AuthorizationInfo authInfo = collector.collect();
|
||||||
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
||||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian"));
|
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian", "user:changeApiKeys:trillian"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,7 +287,8 @@ public class DefaultAuthorizationCollectorTest {
|
|||||||
"repository:user:one",
|
"repository:user:one",
|
||||||
"repository:system:one",
|
"repository:system:one",
|
||||||
"repository:group:two",
|
"repository:group:two",
|
||||||
"user:read:trillian"));
|
"user:read:trillian",
|
||||||
|
"user:changeApiKeys:trillian"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,7 +335,7 @@ public class DefaultAuthorizationCollectorTest {
|
|||||||
AuthorizationInfo authInfo = collector.collect();
|
AuthorizationInfo authInfo = collector.collect();
|
||||||
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER));
|
||||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("one:one", "two:two", "user:read:trillian", "user:autocomplete", "group:autocomplete", "user:changePassword:trillian"));
|
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("one:one", "two:two", "user:read:trillian", "user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:changeApiKeys:trillian"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void authenticate(User user, String group, String... groups) {
|
private void authenticate(User user, String group, String... groups) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ScmWildcardPermissionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldEliminatePermissionsWithDifferentSubject() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("user:write:*");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermissions = permission.limit("repository:write:*");
|
||||||
|
|
||||||
|
assertThat(limitedPermissions).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnScopeIfPermissionImpliesScope() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("*");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read:42");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).get().hasToString("repository:read:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnPermissionIfScopeImpliesPermission() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).get().hasToString("repository:read:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldLimitExplicitParts() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:42,43,44");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read,write,pull:42");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDetectWildcard() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:*");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleMissingEntriesAsWildcard() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldEliminateEmptyVerbs() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:pull:42");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldEliminateEmptyId() {
|
||||||
|
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
|
||||||
|
|
||||||
|
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read:23");
|
||||||
|
|
||||||
|
assertThat(limitedPermission).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,29 +21,32 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.security;
|
package sonia.scm.security;
|
||||||
|
|
||||||
import com.google.common.collect.Collections2;
|
import com.google.common.collect.Collections2;
|
||||||
import com.google.common.collect.Sets;
|
import com.google.common.collect.Sets;
|
||||||
import java.util.Set;
|
|
||||||
import org.apache.shiro.authz.AuthorizationInfo;
|
import org.apache.shiro.authz.AuthorizationInfo;
|
||||||
import org.apache.shiro.authz.Permission;
|
import org.apache.shiro.authz.Permission;
|
||||||
import org.apache.shiro.authz.SimpleAuthorizationInfo;
|
import org.apache.shiro.authz.SimpleAuthorizationInfo;
|
||||||
import org.apache.shiro.authz.permission.WildcardPermission;
|
|
||||||
import org.apache.shiro.authz.permission.WildcardPermissionResolver;
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import static org.junit.Assert.*;
|
|
||||||
import static org.hamcrest.Matchers.*;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
|
import static org.hamcrest.Matchers.emptyCollectionOf;
|
||||||
|
import static org.hamcrest.Matchers.is;
|
||||||
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
|
import static org.junit.Assert.assertThat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for {@link Scopes}.
|
* Unit tests for {@link Scopes}.
|
||||||
*
|
*
|
||||||
* @author Sebastian Sdorra
|
* @author Sebastian Sdorra
|
||||||
*/
|
*/
|
||||||
public class ScopesTest {
|
public class ScopesTest {
|
||||||
|
|
||||||
private final WildcardPermissionResolver resolver = new WildcardPermissionResolver();
|
private final ScmPermissionResolver resolver = new ScmPermissionResolver();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests that filter keep roles.
|
* Tests that filter keep roles.
|
||||||
@@ -51,11 +54,11 @@ public class ScopesTest {
|
|||||||
@Test
|
@Test
|
||||||
public void testFilterKeepRoles(){
|
public void testFilterKeepRoles(){
|
||||||
AuthorizationInfo authz = authz("repository:read:123");
|
AuthorizationInfo authz = authz("repository:read:123");
|
||||||
|
|
||||||
AuthorizationInfo filtered = Scopes.filter(resolver, authz, Scope.empty());
|
AuthorizationInfo filtered = Scopes.filter(resolver, authz, Scope.empty());
|
||||||
assertThat(filtered.getRoles(), containsInAnyOrder("unit", "test"));
|
assertThat(filtered.getRoles(), containsInAnyOrder("unit", "test"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests filter with a simple allow.
|
* Tests filter with a simple allow.
|
||||||
*/
|
*/
|
||||||
@@ -63,10 +66,18 @@ public class ScopesTest {
|
|||||||
public void testFilterSimpleAllow() {
|
public void testFilterSimpleAllow() {
|
||||||
Scope scope = Scope.valueOf("repository:read:123");
|
Scope scope = Scope.valueOf("repository:read:123");
|
||||||
AuthorizationInfo authz = authz("repository:*", "user:*:me");
|
AuthorizationInfo authz = authz("repository:*", "user:*:me");
|
||||||
|
|
||||||
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123");
|
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFilterIntersectingPermissions() {
|
||||||
|
Scope scope = Scope.valueOf("repository:read,write:*");
|
||||||
|
AuthorizationInfo authz = authz("repository:*:123");
|
||||||
|
|
||||||
|
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read,write:123");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests filter with a simple deny.
|
* Tests filter with a simple deny.
|
||||||
*/
|
*/
|
||||||
@@ -74,12 +85,12 @@ public class ScopesTest {
|
|||||||
public void testFilterSimpleDeny() {
|
public void testFilterSimpleDeny() {
|
||||||
Scope scope = Scope.valueOf("repository:read:123");
|
Scope scope = Scope.valueOf("repository:read:123");
|
||||||
AuthorizationInfo authz = authz("user:*:me");
|
AuthorizationInfo authz = authz("user:*:me");
|
||||||
|
|
||||||
AuthorizationInfo filtered = Scopes.filter(resolver, authz, scope);
|
AuthorizationInfo filtered = Scopes.filter(resolver, authz, scope);
|
||||||
assertThat(filtered.getStringPermissions(), is(nullValue()));
|
assertThat(filtered.getStringPermissions(), is(nullValue()));
|
||||||
assertThat(filtered.getObjectPermissions(), is(emptyCollectionOf(Permission.class)));
|
assertThat(filtered.getObjectPermissions(), is(emptyCollectionOf(Permission.class)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests filter with a multiple scope entries.
|
* Tests filter with a multiple scope entries.
|
||||||
*/
|
*/
|
||||||
@@ -87,10 +98,10 @@ public class ScopesTest {
|
|||||||
public void testFilterMultiple() {
|
public void testFilterMultiple() {
|
||||||
Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4");
|
Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4");
|
||||||
AuthorizationInfo authz = authz("repo:read:*");
|
AuthorizationInfo authz = authz("repo:read:*");
|
||||||
|
|
||||||
assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:2");
|
assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:1", "repo:read:2", "repo:read:3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests filter with admin permissions.
|
* Tests filter with admin permissions.
|
||||||
*/
|
*/
|
||||||
@@ -98,10 +109,10 @@ public class ScopesTest {
|
|||||||
public void testFilterAdmin(){
|
public void testFilterAdmin(){
|
||||||
Scope scope = Scope.valueOf("repository:*", "user:*:me");
|
Scope scope = Scope.valueOf("repository:*", "user:*:me");
|
||||||
AuthorizationInfo authz = authz("*");
|
AuthorizationInfo authz = authz("*");
|
||||||
|
|
||||||
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:*", "user:*:me");
|
assertPermissions(Scopes.filter(resolver, authz, scope), "repository:*", "user:*:me");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests filter with requested admin permissions from a non admin.
|
* Tests filter with requested admin permissions from a non admin.
|
||||||
*/
|
*/
|
||||||
@@ -109,29 +120,27 @@ public class ScopesTest {
|
|||||||
public void testFilterRequestAdmin(){
|
public void testFilterRequestAdmin(){
|
||||||
Scope scope = Scope.valueOf("*");
|
Scope scope = Scope.valueOf("*");
|
||||||
AuthorizationInfo authz = authz("repository:*");
|
AuthorizationInfo authz = authz("repository:*");
|
||||||
|
|
||||||
assertThat(
|
assertPermissions(Scopes.filter(resolver, authz, scope),
|
||||||
Scopes.filter(resolver, authz, scope).getObjectPermissions(),
|
"repository:*");
|
||||||
is(emptyCollectionOf(Permission.class))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertPermissions(AuthorizationInfo authz, Object... permissions) {
|
private void assertPermissions(AuthorizationInfo authz, Object... permissions) {
|
||||||
assertThat(authz.getStringPermissions(), is(nullValue()));
|
assertThat(authz.getStringPermissions(), is(nullValue()));
|
||||||
assertThat(
|
assertThat(
|
||||||
Collections2.transform(authz.getObjectPermissions(), Permission::toString),
|
Collections2.transform(authz.getObjectPermissions(), Permission::toString),
|
||||||
containsInAnyOrder(permissions)
|
containsInAnyOrder(permissions)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthorizationInfo authz( String... values ) {
|
private AuthorizationInfo authz( String... values ) {
|
||||||
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test"));
|
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test"));
|
||||||
Set<Permission> permissions = Sets.newLinkedHashSet();
|
Set<Permission> permissions = Sets.newLinkedHashSet();
|
||||||
for ( String value : values ) {
|
for ( String value : values ) {
|
||||||
permissions.add(new WildcardPermission(value));
|
permissions.add(new ScmWildcardPermission(value));
|
||||||
}
|
}
|
||||||
info.setObjectPermissions(permissions);
|
info.setObjectPermissions(permissions);
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user