implement react-query for edge-cases (#1711)

When initially implementing react-query, we focussed on core features. This pull request now replaces the remaining apiClient usages in ui-components and ui-webapp with react-query hooks.
This commit is contained in:
Konstantin Schaper
2021-06-28 13:19:03 +02:00
committed by GitHub
parent 2cd46ce8a0
commit e1239aff92
50 changed files with 1101 additions and 1116 deletions

View File

@@ -0,0 +1,82 @@
/*
* 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 { ApiKey, ApiKeyCreation, ApiKeysCollection, ApiKeyWithToken, Me, User } from "@scm-manager/ui-types";
import { ApiResult } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
const CONTENT_TYPE_API_KEY = "application/vnd.scmm-apiKey+json;v=2";
export const useApiKeys = (user: User | Me): ApiResult<ApiKeysCollection> =>
useQuery(["user", user.name, "apiKeys"], () => apiClient.get(requiredLink(user, "apiKeys")).then((r) => r.json()));
const createApiKey =
(link: string) =>
async (key: ApiKeyCreation): Promise<ApiKeyWithToken> => {
const creationResponse = await apiClient.post(link, key, CONTENT_TYPE_API_KEY);
const location = creationResponse.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
const locationResponse = await apiClient.get(location);
const [apiKey, token] = await Promise.all<ApiKey, string>([locationResponse.json(), creationResponse.text()]);
return { ...apiKey, token } as ApiKeyWithToken;
};
export const useCreateApiKey = (user: User | Me, apiKeys: ApiKeysCollection) => {
const queryClient = useQueryClient();
const { mutate, data, isLoading, error, reset } = useMutation<ApiKeyWithToken, Error, ApiKeyCreation>(
createApiKey(requiredLink(apiKeys, "create")),
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "apiKeys"]),
}
);
return {
create: (key: ApiKeyCreation) => mutate(key),
isLoading,
error,
apiKey: data,
reset,
};
};
export const useDeleteApiKey = (user: User | Me) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, ApiKey>(
(apiKey) => {
const deleteUrl = requiredLink(apiKey, "delete");
return apiClient.delete(deleteUrl);
},
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "apiKeys"]),
}
);
return {
remove: (apiKey: ApiKey) => mutate(apiKey),
isLoading,
error,
isDeleted: !!data,
};
};

View File

@@ -22,22 +22,24 @@
* SOFTWARE.
*/
import { IndexResources, Link } from "@scm-manager/ui-types";
import { HalRepresentation, IndexResources, Link } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { useLegacyContext } from "./LegacyContext";
import { MissingLinkError, UnauthorizedError } from "./errors";
import { requiredLink } from "./links";
export type ApiResult<T> = {
isLoading: boolean;
error: Error | null;
data?: T;
};
export type DeleteFunction<T> = (entity: T) => void;
export const useIndex = (): ApiResult<IndexResources> => {
const legacy = useLegacyContext();
return useQuery<IndexResources, Error>("index", () => apiClient.get("/").then(response => response.json()), {
onSuccess: index => {
return useQuery<IndexResources, Error>("index", () => apiClient.get("/").then((response) => response.json()), {
onSuccess: (index) => {
// ensure legacy code is notified
if (legacy.onIndexFetched) {
legacy.onIndexFetched(index);
@@ -49,7 +51,7 @@ export const useIndex = (): ApiResult<IndexResources> => {
// This only happens once because the error response automatically invalidates the cookie.
// In this event, we have to try the request once again.
return error instanceof UnauthorizedError && failureCount === 0;
}
},
});
};
@@ -94,7 +96,20 @@ export const useVersion = (): string => {
export const useIndexJsonResource = <T>(name: string): ApiResult<T> => {
const link = useIndexLink(name);
return useQuery<T, Error>(name, () => apiClient.get(link!).then(response => response.json()), {
enabled: !!link
return useQuery<T, Error>(name, () => apiClient.get(link!).then((response) => response.json()), {
enabled: !!link,
});
};
export const useJsonResource = <T>(entity: HalRepresentation, name: string, key: string[]): ApiResult<T> =>
useQuery<T, Error>(key, () => apiClient.get(requiredLink(entity, name)).then((response) => response.json()));
export function fetchResourceFromLocationHeader(response: Response) {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
}
export const getResponseJson = (response: Response) => response.json();

View File

@@ -36,9 +36,9 @@ describe("Test branches hooks", () => {
type: "hg",
_links: {
branches: {
href: "/hog/branches"
}
}
href: "/hog/branches",
},
},
};
const develop: Branch = {
@@ -46,16 +46,16 @@ describe("Test branches hooks", () => {
revision: "42",
_links: {
delete: {
href: "/hog/branches/develop"
}
}
href: "/hog/branches/develop",
},
},
};
const branches: BranchCollection = {
_embedded: {
branches: [develop]
branches: [develop],
},
_links: {}
_links: {},
};
const queryClient = createInfiniteCachingClient();
@@ -73,7 +73,7 @@ describe("Test branches hooks", () => {
fetchMock.getOnce("/api/v2/hog/branches", branches);
const { result, waitFor } = renderHook(() => useBranches(repository), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
await waitFor(() => {
return !!result.current.data;
@@ -94,7 +94,7 @@ describe("Test branches hooks", () => {
"repository",
"hitchhiker",
"heart-of-gold",
"branches"
"branches",
]);
expect(data).toEqual(branches);
});
@@ -105,7 +105,7 @@ describe("Test branches hooks", () => {
fetchMock.getOnce("/api/v2/hog/branches/develop", develop);
const { result, waitFor } = renderHook(() => useBranch(repository, "develop"), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
expect(result.error).toBeUndefined();
@@ -128,14 +128,14 @@ describe("Test branches hooks", () => {
fetchMock.postOnce("/api/v2/hog/branches", {
status: 201,
headers: {
Location: "/hog/branches/develop"
}
Location: "/hog/branches/develop",
},
});
fetchMock.getOnce("/api/v2/hog/branches/develop", develop);
const { result, waitForNextUpdate } = renderHook(() => useCreateBranch(repository), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
await act(() => {
@@ -160,7 +160,7 @@ describe("Test branches hooks", () => {
"hitchhiker",
"heart-of-gold",
"branch",
"develop"
"develop",
]);
expect(branch).toEqual(develop);
});
@@ -177,11 +177,11 @@ describe("Test branches hooks", () => {
describe("useDeleteBranch tests", () => {
const deleteBranch = async () => {
fetchMock.deleteOnce("/api/v2/hog/branches/develop", {
status: 204
status: 204,
});
const { result, waitForNextUpdate } = renderHook(() => useDeleteBranch(repository), {
wrapper: createWrapper(undefined, queryClient)
wrapper: createWrapper(undefined, queryClient),
});
await act(() => {
@@ -198,12 +198,12 @@ describe("Test branches hooks", () => {
expect(isDeleted).toBe(true);
});
it("should invalidate branch", async () => {
it("should delete branch cache", async () => {
queryClient.setQueryData(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"], develop);
await deleteBranch();
const queryState = queryClient.getQueryState(["repository", "hitchhiker", "heart-of-gold", "branch", "develop"]);
expect(queryState!.isInvalidated).toBe(true);
expect(queryState).toBeUndefined();
});
it("should invalidate cached branches list", async () => {

View File

@@ -88,7 +88,7 @@ export const useDeleteBranch = (repository: Repository) => {
},
{
onSuccess: async (_, branch) => {
await queryClient.invalidateQueries(branchQueryKey(repository, branch));
queryClient.removeQueries(branchQueryKey(repository, branch));
await queryClient.invalidateQueries(repoQueryKey(repository, "branches"));
}
}

View File

@@ -21,21 +21,13 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { File } from "@scm-manager/ui-types";
import { useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
import { ApiResult } from "./base";
import { apiClient } from "@scm-manager/ui-components";
export const CONTENT_TYPE_PASSWORD_CHANGE = "application/vnd.scmm-passwordChange+json;v=2";
export function changePassword(url: string, oldPassword: string, newPassword: string) {
return apiClient
.put(
url,
{
oldPassword,
newPassword
},
CONTENT_TYPE_PASSWORD_CHANGE
)
.then(response => {
return response;
});
}
export const useFileContent = (file: File): ApiResult<string> => {
const selfLink = requiredLink(file, "self");
return useQuery(["fileContent", selfLink], () => apiClient.get(selfLink).then((response) => response.text()));
};

View File

@@ -22,11 +22,93 @@
* SOFTWARE.
*/
import { ApiResult, useRequiredIndexLink } from "./base";
import { useQuery } from "react-query";
import { ApiResult, fetchResourceFromLocationHeader, getResponseJson, useRequiredIndexLink } from "./base";
import { useMutation, useQuery } from "react-query";
import { apiClient } from "./apiclient";
import { Repository, RepositoryCreation, RepositoryType, RepositoryUrlImport } from "@scm-manager/ui-types";
import { requiredLink } from "./links";
export const useImportLog = (logId: string) : ApiResult<string> => {
export const useImportLog = (logId: string): ApiResult<string> => {
const link = useRequiredIndexLink("importLog").replace("{logId}", logId);
return useQuery<string, Error>(["importLog", logId], () => apiClient.get(link).then(response => response.text()));
}
return useQuery<string, Error>(["importLog", logId], () => apiClient.get(link).then((response) => response.text()));
};
export const useImportRepositoryFromUrl = (repositoryType: RepositoryType) => {
const url = requiredLink(repositoryType, "import", "url");
const { isLoading, error, data, mutate } = useMutation<Repository, Error, RepositoryUrlImport>((repo) =>
apiClient
.post(url, repo, "application/vnd.scmm-repository+json;v=2")
.then(fetchResourceFromLocationHeader)
.then(getResponseJson)
);
return {
isLoading,
error,
importRepositoryFromUrl: (repository: RepositoryUrlImport) => mutate(repository),
importedRepository: data,
};
};
const importRepository = (url: string, repository: RepositoryCreation, file: File, password?: string) => {
return apiClient
.postBinary(url, (formData) => {
formData.append("bundle", file, file?.name);
formData.append("repository", JSON.stringify({ ...repository, password }));
})
.then(fetchResourceFromLocationHeader)
.then(getResponseJson);
};
type ImportRepositoryFromBundleRequest = {
repository: RepositoryCreation;
file: File;
compressed?: boolean;
password?: string;
};
export const useImportRepositoryFromBundle = (repositoryType: RepositoryType) => {
const url = requiredLink(repositoryType, "import", "bundle");
const { isLoading, error, data, mutate } = useMutation<Repository, Error, ImportRepositoryFromBundleRequest>(
({ repository, file, compressed, password }) =>
importRepository(compressed ? url + "?compressed=true" : url, repository, file, password)
);
return {
isLoading,
error,
importRepositoryFromBundle: (repository: RepositoryCreation, file: File, compressed?: boolean, password?: string) =>
mutate({
repository,
file,
compressed,
password,
}),
importedRepository: data,
};
};
type ImportFullRepositoryRequest = {
repository: RepositoryCreation;
file: File;
password?: string;
};
export const useImportFullRepository = (repositoryType: RepositoryType) => {
const { isLoading, error, data, mutate } = useMutation<Repository, Error, ImportFullRepositoryRequest>(
({ repository, file, password }) =>
importRepository(requiredLink(repositoryType, "import", "fullImport"), repository, file, password)
);
return {
isLoading,
error,
importFullRepository: (repository: RepositoryCreation, file: File, password?: string) =>
mutate({
repository,
file,
password,
}),
importedRepository: data,
};
};

View File

@@ -50,6 +50,9 @@ export * from "./import";
export * from "./diff";
export * from "./notifications";
export * from "./configLink";
export * from "./apiKeys";
export * from "./publicKeys";
export * from "./fileContent";
export * from "./history";
export * from "./contentType";
export * from "./annotations";

View File

@@ -60,4 +60,40 @@ describe("requireLink tests", () => {
};
expect(() => requiredLink(object, "spaceship")).toThrowError();
});
it("should return sub-link if it exists", () => {
const object = {
_links: {
spaceship: [
{
name: "one",
href: "/v2/one"
},
{
name: "two",
href: "/v2/two"
}
]
}
};
expect(requiredLink(object, "spaceship", "one")).toBe("/v2/one");
});
it("should throw error, if sub-link does not exist in link array", () => {
const object = {
_links: {
spaceship: [
{
name: "one",
href: "/v2/one"
},
{
name: "two",
href: "/v2/two"
}
]
}
};
expect(() => requiredLink(object, "spaceship", "three")).toThrowError();
});
});

View File

@@ -21,14 +21,32 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { HalRepresentation } from "@scm-manager/ui-types";
import { HalRepresentation, Link } from "@scm-manager/ui-types";
import { MissingLinkError } from "./errors";
export const requiredLink = (object: HalRepresentation, name: string) => {
export const requiredLink = (object: HalRepresentation, name: string, subName?: string): string => {
const link = object._links[name];
if (!link) {
throw new MissingLinkError(`could not find link with name ${name}`);
}
if (Array.isArray(link)) {
if (subName) {
const subLink = link.find((l: Link) => l.name === subName);
if (subLink) {
return subLink.href;
}
throw new Error(`could not return href, sub-link ${subName} in ${name} does not exist`);
}
throw new Error(`could not return href, link ${name} is a multi link`);
}
return link.href;
};
export const objectLink = (object: HalRepresentation, name: string) => {
const link = object._links[name];
if (!link) {
return null;
}
if (Array.isArray(link)) {
throw new Error(`could not return href, link ${name} is a multi link`);
}

View File

@@ -21,18 +21,21 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { ApiResult, useIndexJsonResource } from "./base";
import { ApiResult, useIndexJsonResource, useJsonResource } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
GlobalPermissionsCollection,
Group,
Namespace,
Permission,
PermissionCollection,
PermissionCreateEntry,
Repository,
RepositoryVerbs
RepositoryVerbs,
User,
} from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
import { objectLink, requiredLink } from "./links";
import { repoQueryKey } from "./keys";
import { useRepositoryRoles } from "./repository-roles";
@@ -40,6 +43,9 @@ export const useRepositoryVerbs = (): ApiResult<RepositoryVerbs> => {
return useIndexJsonResource<RepositoryVerbs>("repositoryVerbs");
};
/**
* *IMPORTANT NOTE:* These are actually *REPOSITORY* permissions.
*/
export const useAvailablePermissions = () => {
const roles = useRepositoryRoles();
const verbs = useRepositoryVerbs();
@@ -47,14 +53,14 @@ export const useAvailablePermissions = () => {
if (roles.data && verbs.data) {
data = {
repositoryVerbs: verbs.data.verbs,
repositoryRoles: roles.data._embedded.repositoryRoles
repositoryRoles: roles.data._embedded.repositoryRoles,
};
}
return {
isLoading: roles.isLoading || verbs.isLoading,
error: roles.error || verbs.error,
data
data,
};
};
@@ -73,21 +79,21 @@ const createQueryKey = (namespaceOrRepository: Namespace | Repository) => {
export const usePermissions = (namespaceOrRepository: Namespace | Repository): ApiResult<PermissionCollection> => {
const link = requiredLink(namespaceOrRepository, "permissions");
const queryKey = createQueryKey(namespaceOrRepository);
return useQuery<PermissionCollection, Error>(queryKey, () => apiClient.get(link).then(response => response.json()));
return useQuery<PermissionCollection, Error>(queryKey, () => apiClient.get(link).then((response) => response.json()));
};
const createPermission = (link: string) => {
return (permission: PermissionCreateEntry) => {
return apiClient
.post(link, permission, "application/vnd.scmm-repositoryPermission+json")
.then(response => {
.then((response) => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
.then((response) => response.json());
};
};
@@ -100,21 +106,21 @@ export const useCreatePermission = (namespaceOrRepository: Namespace | Repositor
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
},
}
);
return {
isLoading,
error,
create: (permission: PermissionCreateEntry) => mutate(permission),
permission: data
permission: data,
};
};
export const useUpdatePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const { isLoading, error, mutate, data } = useMutation<unknown, Error, Permission>(
permission => {
(permission) => {
const link = requiredLink(permission, "update");
return apiClient.put(link, permission, "application/vnd.scmm-repositoryPermission+json");
},
@@ -122,21 +128,21 @@ export const useUpdatePermission = (namespaceOrRepository: Namespace | Repositor
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
},
}
);
return {
isLoading,
error,
update: (permission: Permission) => mutate(permission),
isUpdated: !!data
isUpdated: !!data,
};
};
export const useDeletePermission = (namespaceOrRepository: Namespace | Repository) => {
const queryClient = useQueryClient();
const { isLoading, error, mutate, data } = useMutation<unknown, Error, Permission>(
permission => {
(permission) => {
const link = requiredLink(permission, "delete");
return apiClient.delete(link);
},
@@ -144,13 +150,53 @@ export const useDeletePermission = (namespaceOrRepository: Namespace | Repositor
onSuccess: () => {
const queryKey = createQueryKey(namespaceOrRepository);
return queryClient.invalidateQueries(queryKey);
}
},
}
);
return {
isLoading,
error,
remove: (permission: Permission) => mutate(permission),
isDeleted: !!data
isDeleted: !!data,
};
};
const userPermissionsKey = (user: User) => ["user", user.name, "permissions"];
const groupPermissionsKey = (group: Group) => ["group", group.name, "permissions"];
export const useGroupPermissions = (group: Group) =>
useJsonResource<GlobalPermissionsCollection>(group, "permissions", groupPermissionsKey(group));
export const useUserPermissions = (user: User) =>
useJsonResource<GlobalPermissionsCollection>(user, "permissions", userPermissionsKey(user));
export const useAvailableGlobalPermissions = () =>
useIndexJsonResource<Omit<GlobalPermissionsCollection, "_links">>("permissions");
const useSetEntityPermissions = (permissionCollection?: GlobalPermissionsCollection, key?: string[]) => {
const queryClient = useQueryClient();
const url = permissionCollection ? objectLink(permissionCollection, "overwrite") : null;
const { isLoading, error, mutate, data } = useMutation<unknown, Error, string[]>(
(permissions) =>
apiClient.put(
url!,
{
permissions,
},
"application/vnd.scmm-permissionCollection+json;v=2"
),
{
onSuccess: () => queryClient.invalidateQueries(key),
}
);
const setPermissions = (permissions: string[]) => mutate(permissions);
return {
isLoading,
error,
setPermissions: url ? setPermissions : undefined,
isUpdated: !!data,
};
};
export const useSetUserPermissions = (user: User, permissions?: GlobalPermissionsCollection) =>
useSetEntityPermissions(permissions, userPermissionsKey(user));
export const useSetGroupPermissions = (group: Group, permissions?: GlobalPermissionsCollection) =>
useSetEntityPermissions(permissions, groupPermissionsKey(group));

View File

@@ -0,0 +1,83 @@
/*
* 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 { Me, PublicKey, PublicKeyCreation, PublicKeysCollection, User } from "@scm-manager/ui-types";
import { ApiResult } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { requiredLink } from "./links";
export const CONTENT_TYPE_PUBLIC_KEY = "application/vnd.scmm-publicKey+json;v=2";
export const usePublicKeys = (user: User | Me): ApiResult<PublicKeysCollection> =>
useQuery(["user", user.name, "publicKeys"], () =>
apiClient.get(requiredLink(user, "publicKeys")).then((r) => r.json())
);
const createPublicKey =
(link: string) =>
async (key: PublicKeyCreation): Promise<PublicKey> => {
const creationResponse = await apiClient.post(link, key, CONTENT_TYPE_PUBLIC_KEY);
const location = creationResponse.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
const apiKeyResponse = await apiClient.get(location);
return apiKeyResponse.json();
};
export const useCreatePublicKey = (user: User | Me, publicKeys: PublicKeysCollection) => {
const queryClient = useQueryClient();
const { mutate, data, isLoading, error, reset } = useMutation<PublicKey, Error, PublicKeyCreation>(
createPublicKey(requiredLink(publicKeys, "create")),
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "publicKeys"]),
}
);
return {
create: (key: PublicKeyCreation) => mutate(key),
isLoading,
error,
apiKey: data,
reset,
};
};
export const useDeletePublicKey = (user: User | Me) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, PublicKey>(
(publicKey) => {
const deleteUrl = requiredLink(publicKey, "delete");
return apiClient.delete(deleteUrl);
},
{
onSuccess: () => queryClient.invalidateQueries(["user", user.name, "publicKeys"]),
}
);
return {
remove: (publicKey: PublicKey) => mutate(publicKey),
isLoading,
error,
isDeleted: !!data,
};
};

View File

@@ -30,17 +30,17 @@ import {
Repository,
RepositoryCollection,
RepositoryCreation,
RepositoryTypeCollection
RepositoryTypeCollection,
} from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { ApiResult, useIndexJsonResource, useRequiredIndexLink } from "./base";
import { createQueryString } from "./utils";
import { requiredLink } from "./links";
import { objectLink, requiredLink } from "./links";
import { repoQueryKey } from "./keys";
import { concat } from "./urls";
import { useEffect, useState } from "react";
import { NotFoundError } from "./errors";
import { MissingLinkError, NotFoundError } from "./errors";
export type UseRepositoriesRequest = {
namespace?: Namespace;
@@ -56,7 +56,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
const link = namespaceLink || indexLink;
const queryParams: Record<string, string> = {
sortBy: "namespaceAndName"
sortBy: "namespaceAndName",
};
if (request?.search) {
queryParams.q = request.search;
@@ -66,7 +66,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
}
return useQuery<RepositoryCollection, Error>(
["repositories", request?.namespace?.namespace, request?.search || "", request?.page || 0],
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then(response => response.json()),
() => apiClient.get(`${link}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
enabled: !request?.disabled,
onSuccess: (repositories: RepositoryCollection) => {
@@ -74,7 +74,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult<Rep
repositories._embedded.repositories.forEach((repository: Repository) => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
});
}
},
}
);
};
@@ -92,14 +92,14 @@ const createRepository = (link: string) => {
}
return apiClient
.post(createLink, request.repository, "application/vnd.scmm-repository+json;v=2")
.then(response => {
.then((response) => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
.then((response) => response.json());
};
};
@@ -111,10 +111,10 @@ export const useCreateRepository = () => {
const { mutate, data, isLoading, error } = useMutation<Repository, Error, CreateRepositoryRequest>(
createRepository(link),
{
onSuccess: repository => {
onSuccess: (repository) => {
queryClient.setQueryData(["repository", repository.namespace, repository.name], repository);
return queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
@@ -123,7 +123,7 @@ export const useCreateRepository = () => {
},
isLoading,
error,
repository: data
repository: data,
};
};
@@ -133,7 +133,7 @@ export const useRepositoryTypes = () => useIndexJsonResource<RepositoryTypeColle
export const useRepository = (namespace: string, name: string): ApiResult<Repository> => {
const link = useRequiredIndexLink("repositories");
return useQuery<Repository, Error>(["repository", namespace, name], () =>
apiClient.get(concat(link, namespace, name)).then(response => response.json())
apiClient.get(concat(link, namespace, name)).then((response) => response.json())
);
};
@@ -144,7 +144,7 @@ export type UseDeleteRepositoryOptions = {
export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "delete");
return apiClient.delete(link);
},
@@ -155,21 +155,21 @@ export const useDeleteRepository = (options?: UseDeleteRepositoryOptions) => {
}
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
remove: (repository: Repository) => mutate(repository),
isLoading,
error,
isDeleted: !!data
isDeleted: !!data,
};
};
export const useUpdateRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "update");
return apiClient.put(link, repository, "application/vnd.scmm-repository+json;v=2");
},
@@ -177,21 +177,21 @@ export const useUpdateRepository = () => {
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
update: (repository: Repository) => mutate(repository),
isLoading,
error,
isUpdated: !!data
isUpdated: !!data,
};
};
export const useArchiveRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "archive");
return apiClient.post(link);
},
@@ -199,21 +199,21 @@ export const useArchiveRepository = () => {
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
archive: (repository: Repository) => mutate(repository),
isLoading,
error,
isArchived: !!data
isArchived: !!data,
};
};
export const useUnarchiveRepository = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "unarchive");
return apiClient.post(link);
},
@@ -221,35 +221,35 @@ export const useUnarchiveRepository = () => {
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
unarchive: (repository: Repository) => mutate(repository),
isLoading,
error,
isUnarchived: !!data
isUnarchived: !!data,
};
};
export const useRunHealthCheck = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
repository => {
(repository) => {
const link = requiredLink(repository, "runHealthCheck");
return apiClient.post(link);
},
{
onSuccess: async (_, repository) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
}
},
}
);
return {
runHealthCheck: (repository: Repository) => mutate(repository),
isLoading,
error,
isRunning: !!data
isRunning: !!data,
};
};
@@ -258,14 +258,14 @@ export const useExportInfo = (repository: Repository): ApiResult<ExportInfo> =>
//TODO Refetch while exporting to update the page
const { isLoading, error, data } = useQuery<ExportInfo, Error>(
["repository", repository.namespace, repository.name, "exportInfo"],
() => apiClient.get(link).then(response => response.json()),
() => apiClient.get(link).then((response) => response.json()),
{}
);
return {
isLoading,
error: error instanceof NotFoundError ? null : error,
data
data,
};
};
@@ -308,14 +308,14 @@ export const useExportRepository = () => {
const id = setInterval(() => {
apiClient
.get(infolink)
.then(r => r.json())
.then((r) => r.json())
.then((info: ExportInfo) => {
if (info._links.download) {
clearInterval(id);
resolve(info);
}
})
.catch(e => {
.catch((e) => {
clearInterval(id);
reject(e);
});
@@ -328,20 +328,49 @@ export const useExportRepository = () => {
onSuccess: async (_, { repository }) => {
await queryClient.invalidateQueries(repoQueryKey(repository));
await queryClient.invalidateQueries(["repositories"]);
}
},
}
);
return {
exportRepository: (repository: Repository, options: ExportOptions) => mutate({ repository, options }),
isLoading,
error,
data
data,
};
};
export const usePaths = (repository: Repository, revision: string): ApiResult<Paths> => {
const link = requiredLink(repository, "paths").replace("{revision}", revision);
return useQuery<Paths, Error>(repoQueryKey(repository, "paths", revision), () =>
apiClient.get(link).then(response => response.json())
apiClient.get(link).then((response) => response.json())
);
};
type RenameRepositoryRequest = {
name: string;
namespace: string;
};
export const useRenameRepository = (repository: Repository) => {
const queryClient = useQueryClient();
const url = objectLink(repository, "renameWithNamespace") || objectLink(repository, "rename");
if (!url) {
throw new MissingLinkError(`could not find rename link on repository ${repository.namespace}/${repository.name}`);
}
const { mutate, isLoading, error, data } = useMutation<unknown, Error, RenameRepositoryRequest>(
({ name, namespace }) => apiClient.post(url, { namespace, name }, "application/vnd.scmm-repository+json;v=2"),
{
onSuccess: () => queryClient.removeQueries(repoQueryKey(repository))
}
);
return {
renameRepository: (namespace: string, name: string) => mutate({ namespace, name }),
isLoading,
error,
isRenamed: !!data,
};
};

View File

@@ -24,10 +24,11 @@
import { ApiResult, useRequiredIndexLink } from "./base";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, User, UserCollection, UserCreation } from "@scm-manager/ui-types";
import {Link, Me, User, UserCollection, UserCreation} from "@scm-manager/ui-types";
import { apiClient } from "./apiclient";
import { createQueryString } from "./utils";
import { concat } from "./urls";
import { requiredLink } from "./links";
export type UseUsersRequest = {
page?: number | string;
@@ -48,11 +49,11 @@ export const useUsers = (request?: UseUsersRequest): ApiResult<UserCollection> =
return useQuery<UserCollection, Error>(
["users", request?.search || "", request?.page || 0],
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then(response => response.json()),
() => apiClient.get(`${indexLink}?${createQueryString(queryParams)}`).then((response) => response.json()),
{
onSuccess: (users: UserCollection) => {
users._embedded.users.forEach((user: User) => queryClient.setQueryData(["user", user.name], user));
}
},
}
);
};
@@ -60,7 +61,7 @@ export const useUsers = (request?: UseUsersRequest): ApiResult<UserCollection> =
export const useUser = (name: string): ApiResult<User> => {
const indexLink = useRequiredIndexLink("users");
return useQuery<User, Error>(["user", name], () =>
apiClient.get(concat(indexLink, name)).then(response => response.json())
apiClient.get(concat(indexLink, name)).then((response) => response.json())
);
};
@@ -68,14 +69,14 @@ const createUser = (link: string) => {
return (user: UserCreation) => {
return apiClient
.post(link, user, "application/vnd.scmm-user+json;v=2")
.then(response => {
.then((response) => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then(response => response.json());
.then((response) => response.json());
};
};
@@ -83,23 +84,23 @@ export const useCreateUser = () => {
const queryClient = useQueryClient();
const link = useRequiredIndexLink("users");
const { mutate, data, isLoading, error } = useMutation<User, Error, UserCreation>(createUser(link), {
onSuccess: user => {
onSuccess: (user) => {
queryClient.setQueryData(["user", user.name], user);
return queryClient.invalidateQueries(["users"]);
}
},
});
return {
create: (user: UserCreation) => mutate(user),
isLoading,
error,
user: data
user: data,
};
};
export const useUpdateUser = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => {
(user) => {
const updateUrl = (user._links.update as Link).href;
return apiClient.put(updateUrl, user, "application/vnd.scmm-user+json;v=2");
},
@@ -107,21 +108,21 @@ export const useUpdateUser = () => {
onSuccess: async (_, user) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
update: (user: User) => mutate(user),
isLoading,
error,
isUpdated: !!data
isUpdated: !!data,
};
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => {
(user) => {
const deleteUrl = (user._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
@@ -129,14 +130,14 @@ export const useDeleteUser = () => {
onSuccess: async (_, name) => {
await queryClient.invalidateQueries(["user", name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
remove: (user: User) => mutate(user),
isLoading,
error,
isDeleted: !!data
isDeleted: !!data,
};
};
@@ -144,7 +145,7 @@ const convertToInternal = (url: string, newPassword: string) => {
return apiClient.put(
url,
{
newPassword
newPassword,
},
"application/vnd.scmm-user+json;v=2"
);
@@ -167,32 +168,73 @@ export const useConvertToInternal = () => {
onSuccess: async (_, { user }) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
convertToInternal: (user: User, password: string) => mutate({ user, password }),
isLoading,
error,
isConverted: !!data
isConverted: !!data,
};
};
export const useConvertToExternal = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, User>(
user => convertToExternal((user._links.convertToExternal as Link).href),
(user) => convertToExternal((user._links.convertToExternal as Link).href),
{
onSuccess: async (_, user) => {
await queryClient.invalidateQueries(["user", user.name]);
await queryClient.invalidateQueries(["users"]);
}
},
}
);
return {
convertToExternal: (user: User) => mutate(user),
isLoading,
error,
isConverted: !!data
isConverted: !!data,
};
};
const CONTENT_TYPE_PASSWORD_OVERWRITE = "application/vnd.scmm-passwordOverwrite+json;v=2";
export const useSetUserPassword = (user: User) => {
const { data, isLoading, error, mutate, reset } = useMutation<unknown, Error, string>((password) =>
apiClient.put(
requiredLink(user, "password"),
{
newPassword: password,
},
CONTENT_TYPE_PASSWORD_OVERWRITE
)
);
return {
setPassword: (newPassword: string) => mutate(newPassword),
passwordOverwritten: !!data,
isLoading,
error,
reset
};
};
const CONTENT_TYPE_PASSWORD_CHANGE = "application/vnd.scmm-passwordChange+json;v=2";
type ChangeUserPasswordRequest = {
oldPassword: string;
newPassword: string;
};
export const useChangeUserPassword = (user: User | Me) => {
const { data, isLoading, error, mutate, reset } = useMutation<unknown, Error, ChangeUserPasswordRequest>((request) =>
apiClient.put(requiredLink(user, "password"), request, CONTENT_TYPE_PASSWORD_CHANGE)
);
return {
changePassword: (oldPassword: string, newPassword: string) => mutate({ oldPassword, newPassword }),
passwordChanged: !!data,
isLoading,
error,
reset
};
};

View File

@@ -24,7 +24,6 @@
import React, { ChangeEvent, FC, FocusEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { File } from "@scm-manager/ui-types";
import { createAttributesForTesting } from "../devBuild";
import LabelWithHelpIcon from "./LabelWithHelpIcon";

View File

@@ -24,7 +24,6 @@
import React, { FC, useState, ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
import { File } from "@scm-manager/ui-types";
type Props = {
handleFile: (file: File, event?: ChangeEvent<HTMLInputElement>) => void;

View File

@@ -21,28 +21,23 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
import fetchMock from "fetch-mock";
import { CONTENT_TYPE_PASSWORD_OVERWRITE, setPassword } from "./setPassword";
export type ApiKeysCollection = HalRepresentationWithEmbedded<{ keys: ApiKey[] }>;
describe("password change", () => {
const SET_PASSWORD_URL = "/users/testuser/password";
const newPassword = "testpw123";
export type ApiKeyBase = {
displayName: string;
permissionRole: string;
};
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
export type ApiKey = HalRepresentation &
ApiKeyBase & {
id: string;
created: string;
};
it("should set password", done => {
fetchMock.put("/api/v2" + SET_PASSWORD_URL, 204, {
headers: {
"content-type": CONTENT_TYPE_PASSWORD_OVERWRITE
}
});
export type ApiKeyWithToken = ApiKey & {
token: string;
};
setPassword(SET_PASSWORD_URL, newPassword).then(content => {
done();
});
});
});
export type ApiKeyCreation = ApiKeyBase;

View File

@@ -21,29 +21,6 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { HalRepresentation } from "./hal";
import fetchMock from "fetch-mock";
import { changePassword, CONTENT_TYPE_PASSWORD_CHANGE } from "./changePassword";
describe("change password", () => {
const CHANGE_PASSWORD_URL = "/me/password";
const oldPassword = "old";
const newPassword = "new";
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should update password", done => {
fetchMock.put("/api/v2" + CHANGE_PASSWORD_URL, 204, {
headers: {
"content-type": CONTENT_TYPE_PASSWORD_CHANGE
}
});
changePassword(CHANGE_PASSWORD_URL, oldPassword, newPassword).then(content => {
done();
});
});
});
export type GlobalPermissionsCollection = HalRepresentation & { permissions: string[] };

View File

@@ -21,21 +21,20 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
import { apiClient } from "@scm-manager/ui-components";
export type PublicKeysCollection = HalRepresentationWithEmbedded<{
keys: PublicKey[];
}>;
export const CONTENT_TYPE_PASSWORD_OVERWRITE = "application/vnd.scmm-passwordOverwrite+json;v=2";
export type PublicKeyBase = {
displayName: string;
raw: string;
};
export function setPassword(url: string, password: string) {
return apiClient
.put(
url,
{
newPassword: password
},
CONTENT_TYPE_PASSWORD_OVERWRITE
)
.then(response => {
return response;
});
}
export type PublicKey = HalRepresentation & PublicKeyBase & {
id: string;
created?: string;
};
export type PublicKeyCreation = PublicKeyBase;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import { HalRepresentationWithEmbedded, Links } from "./hal";
import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
export type PermissionCreateEntry = {
name: string;
@@ -31,9 +31,7 @@ export type PermissionCreateEntry = {
groupPermission: boolean;
};
export type Permission = PermissionCreateEntry & {
_links: Links;
};
export type Permission = PermissionCreateEntry & HalRepresentation;
type PermissionEmbedded = {
permissions: Permission[];

View File

@@ -67,3 +67,6 @@ export * from "./Admin";
export * from "./Diff";
export * from "./Notifications";
export * from "./ApiKeys";
export * from "./PublicKeys";
export * from "./GlobalPermissions";

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC, FormEvent, useEffect, useState } from "react";
import {
ErrorNotification,
InputField,
@@ -29,143 +29,74 @@ import {
Notification,
PasswordConfirmation,
SubmitButton,
Subtitle
Subtitle,
} from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { Me } from "@scm-manager/ui-types";
import { changePassword } from "../utils/changePassword";
import { useChangeUserPassword } from "@scm-manager/ui-api";
type Props = WithTranslation & {
type Props = {
me: Me;
};
type State = {
oldPassword: string;
password: string;
loading: boolean;
error?: Error;
passwordChanged: boolean;
passwordValid: boolean;
const ChangeUserPassword: FC<Props> = ({ me }) => {
const [t] = useTranslation("commons");
const { isLoading, error, passwordChanged, changePassword, reset } = useChangeUserPassword(me);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [passwordValid, setPasswordValid] = useState(false);
useEffect(() => {
if (passwordChanged) {
setOldPassword("");
setNewPassword("");
setPasswordValid(false);
}
}, [passwordChanged]);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (newPassword) {
changePassword(oldPassword, newPassword);
}
};
const onPasswordChange = (newValue: string, valid: boolean) => {
setNewPassword(newValue);
setPasswordValid(!!newValue && valid);
};
let message = null;
if (passwordChanged) {
message = <Notification type={"success"} children={t("password.changedSuccessfully")} onClose={reset} />;
} else if (error) {
message = <ErrorNotification error={error} />;
}
return (
<form onSubmit={submit}>
<Subtitle subtitle={t("password.subtitle")} />
{message}
<div className="columns">
<div className="column">
<InputField
label={t("password.currentPassword")}
type="password"
onChange={setOldPassword}
value={oldPassword}
helpText={t("password.currentPasswordHelpText")}
/>
</div>
</div>
<PasswordConfirmation passwordChanged={onPasswordChange} key={passwordChanged ? "changed" : "unchanged"} />
<Level
right={
<SubmitButton disabled={!passwordValid || !oldPassword} loading={isLoading} label={t("password.submit")} />
}
/>
</form>
);
};
class ChangeUserPassword extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
oldPassword: "",
password: "",
loading: false,
passwordChanged: false,
passwordValid: false
};
}
setLoadingState = () => {
this.setState({
...this.state,
loading: true
});
};
setErrorState = (error: Error) => {
this.setState({
...this.state,
error: error,
loading: false
});
};
setSuccessfulState = () => {
this.setState({
...this.state,
loading: false,
passwordChanged: true,
oldPassword: "",
password: ""
});
};
submit = (event: Event) => {
event.preventDefault();
if (this.state.password) {
const { oldPassword, password } = this.state;
this.setLoadingState();
changePassword(this.props.me._links.password.href, oldPassword, password)
.then(result => {
if (result.error) {
this.setErrorState(result.error);
} else {
this.setSuccessfulState();
}
})
.catch(err => {
this.setErrorState(err);
});
}
};
isValid = () => {
return this.state.oldPassword && this.state.passwordValid;
};
render() {
const { t } = this.props;
const { loading, passwordChanged, error } = this.state;
let message = null;
if (passwordChanged) {
message = (
<Notification type={"success"} children={t("password.changedSuccessfully")} onClose={() => this.onClose()} />
);
} else if (error) {
message = <ErrorNotification error={error} />;
}
return (
<form onSubmit={this.submit}>
<Subtitle subtitle={t("password.subtitle")} />
{message}
<div className="columns">
<div className="column">
<InputField
label={t("password.currentPassword")}
type="password"
onChange={oldPassword =>
this.setState({
...this.state,
oldPassword
})
}
value={this.state.oldPassword ? this.state.oldPassword : ""}
helpText={t("password.currentPasswordHelpText")}
/>
</div>
</div>
<PasswordConfirmation
passwordChanged={this.passwordChanged}
key={this.state.passwordChanged ? "changed" : "unchanged"}
/>
<Level right={<SubmitButton disabled={!this.isValid()} loading={loading} label={t("password.submit")} />} />
</form>
);
}
passwordChanged = (password: string, passwordValid: boolean) => {
this.setState({
...this.state,
password,
passwordValid: !!password && passwordValid
});
};
onClose = () => {
this.setState({
...this.state,
passwordChanged: false
});
};
}
export default withTranslation("commons")(ChangeUserPassword);
export default ChangeUserPassword;

View File

@@ -34,7 +34,7 @@ import {
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation,
urls
urls,
} from "@scm-manager/ui-components";
import ChangeUserPassword from "./ChangeUserPassword";
import ProfileInfo from "./ProfileInfo";
@@ -69,7 +69,7 @@ const Profile: FC = () => {
subtitle={t("profile.error-subtitle")}
error={{
name: t("profile.error"),
message: t("profile.error-message")
message: t("profile.error-message"),
}}
/>
);
@@ -77,7 +77,7 @@ const Profile: FC = () => {
const extensionProps = {
me,
url
url,
};
return (
@@ -94,12 +94,20 @@ const Profile: FC = () => {
</Switch>
)}
{mayChangePassword && (
<Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} />
<Route path={`${url}/settings/password`}>
<ChangeUserPassword me={me} />
</Route>
)}
{canManagePublicKeys && (
<Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me} />} />
<Route path={`${url}/settings/publicKeys`}>
<SetPublicKeys user={me} />
</Route>
)}
{canManageApiKeys && (
<Route path={`${url}/settings/apiKeys`}>
<SetApiKeys user={me} />
</Route>
)}
{canManageApiKeys && <Route path={`${url}/settings/apiKeys`} render={() => <SetApiKeys user={me} />} />}
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn>
<SecondaryNavigationColumn>

View File

@@ -25,7 +25,6 @@ import React, { FC } from "react";
import { Route, useParams, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Link } from "@scm-manager/ui-types";
import {
CustomQueryFlexWrappedColumns,
ErrorPage,
@@ -37,16 +36,16 @@ import {
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation,
urls
urls,
} from "@scm-manager/ui-components";
import { Details } from "./../components/table";
import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks";
import EditGroup from "./EditGroup";
import SetPermissions from "../../permissions/components/SetPermissions";
import { useGroup } from "@scm-manager/ui-api";
import SetGroupPermissions from "../../permissions/components/SetGroupPermissions";
const SingleGroup: FC = () => {
const { name } = useParams();
const { name } = useParams<{ name: string }>();
const match = useRouteMatch();
const { data: group, isLoading, error } = useGroup(name);
const [t] = useTranslation("groups");
@@ -63,7 +62,7 @@ const SingleGroup: FC = () => {
const extensionProps = {
group,
url
url,
};
return (
@@ -78,7 +77,7 @@ const SingleGroup: FC = () => {
<EditGroup group={group} />
</Route>
<Route path={`${url}/settings/permissions`} exact>
<SetPermissions selectedPermissionsLink={group._links.permissions as Link} />
<SetGroupPermissions group={group} />
</Route>
<ExtensionPoint name="group.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn>

View File

@@ -21,33 +21,38 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Group } from "@scm-manager/ui-types";
import React, { FC } from "react";
import SetPermissions from "./SetPermissions";
import { useGroupPermissions, useSetGroupPermissions } from "@scm-manager/ui-api";
import fetchMock from "fetch-mock";
import { getContent, getLanguage } from "./SourcecodeViewer";
type Props = {
group: Group;
};
describe("get content", () => {
const CONTENT_URL = "/repositories/scmadmin/TestRepo/content/testContent";
const SetGroupPermissions: FC<Props> = ({ group }) => {
const {
data: selectedPermissions,
isLoading: loadingPermissions,
error: permissionsLoadError,
} = useGroupPermissions(group);
const {
isLoading: isUpdatingPermissions,
isUpdated: permissionsUpdated,
setPermissions,
error: permissionsUpdateError,
} = useSetGroupPermissions(group, selectedPermissions);
return (
<SetPermissions
selectedPermissions={selectedPermissions}
loadingPermissions={loadingPermissions}
isUpdatingPermissions={isUpdatingPermissions}
permissionsLoadError={permissionsLoadError || undefined}
permissionsUpdated={permissionsUpdated}
updatePermissions={setPermissions}
permissionsUpdateError={permissionsUpdateError || undefined}
/>
);
};
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should return content", done => {
fetchMock.getOnce("/api/v2" + CONTENT_URL, "This is a testContent");
getContent(CONTENT_URL).then(content => {
expect(content).toBe("This is a testContent");
done();
});
});
});
describe("get correct language type", () => {
it("should return javascript", () => {
expect(getLanguage("JAVASCRIPT")).toBe("javascript");
});
it("should return nothing for plain text", () => {
expect(getLanguage("")).toBe("");
});
});
export default SetGroupPermissions;

View File

@@ -23,55 +23,69 @@
*/
import React, { FC, FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "@scm-manager/ui-types";
import { ErrorNotification, Level, Notification, SubmitButton, Subtitle } from "@scm-manager/ui-components";
import { loadPermissionsForEntity, setPermissions as updatePermissions } from "./handlePermissions";
import { ErrorNotification, Level, Loading, Notification, SubmitButton, Subtitle } from "@scm-manager/ui-components";
import PermissionsWrapper from "./PermissionsWrapper";
import { useRequiredIndexLink } from "@scm-manager/ui-api";
import { useAvailableGlobalPermissions } from "@scm-manager/ui-api";
import { GlobalPermissionsCollection } from "@scm-manager/ui-types";
type Props = {
selectedPermissionsLink: Link;
selectedPermissions?: GlobalPermissionsCollection;
loadingPermissions?: boolean;
permissionsLoadError?: Error;
updatePermissions?: (permissions: string[]) => void;
isUpdatingPermissions?: boolean;
permissionsUpdated?: boolean;
permissionsUpdateError?: Error;
};
const SetPermissions: FC<Props> = ({ selectedPermissionsLink }) => {
const SetPermissions: FC<Props> = ({
loadingPermissions,
isUpdatingPermissions,
permissionsLoadError,
permissionsUpdateError,
updatePermissions,
permissionsUpdated,
selectedPermissions,
}) => {
const [t] = useTranslation("permissions");
const availablePermissionLink = useRequiredIndexLink("permissions");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null | undefined>();
const {
data: availablePermissions,
error: availablePermissionsLoadError,
isLoading: isLoadingAvailablePermissions,
} = useAvailableGlobalPermissions();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [permissionsSubmitted, setPermissionsSubmitted] = useState(false);
const [permissionsChanged, setPermissionsChanged] = useState(false);
const [overwritePermissionsLink, setOverwritePermissionsLink] = useState<Link | undefined>();
const [permissions, setPermissions] = useState<{
[key: string]: boolean;
}>({});
const error = permissionsLoadError || availablePermissionsLoadError;
useEffect(() => {
loadPermissionsForEntity(availablePermissionLink, selectedPermissionsLink.href).then(response => {
const { permissions, overwriteLink } = response;
setPermissions(permissions);
setOverwritePermissionsLink(overwriteLink);
setLoading(false);
});
}, [availablePermissionLink, selectedPermissionsLink]);
if (selectedPermissions && availablePermissions) {
const newPermissions: Record<string, boolean> = {};
availablePermissions.permissions.forEach((p) => (newPermissions[p] = false));
selectedPermissions.permissions.forEach((p) => (newPermissions[p] = true));
setPermissions(newPermissions);
}
}, [availablePermissions, selectedPermissions]);
const setLoadingState = () => setLoading(true);
useEffect(() => {
if (permissionsUpdated) {
setPermissionsSubmitted(true);
setPermissionsChanged(false);
}
}, [permissionsUpdated]);
const setErrorState = (error: Error) => {
setLoading(false);
setError(error);
};
if (loadingPermissions || isLoadingAvailablePermissions) {
return <Loading />;
}
const setSuccessfulState = () => {
setLoading(false);
setError(undefined);
setPermissionsSubmitted(true);
setPermissionsChanged(false);
};
if (error) {
return <ErrorNotification error={error} />;
}
const valueChanged = (value: boolean, name: string) => {
setPermissions({
...permissions,
[name]: value
[name]: value,
});
setPermissionsChanged(true);
};
@@ -81,15 +95,10 @@ const SetPermissions: FC<Props> = ({ selectedPermissionsLink }) => {
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (permissions) {
setLoadingState();
const selectedPermissions = Object.entries(permissions)
.filter(e => e[1])
.map(e => e[0]);
if (overwritePermissionsLink) {
updatePermissions(overwritePermissionsLink.href, selectedPermissions)
.then(_ => setSuccessfulState())
.catch(err => setErrorState(err));
}
.filter((e) => e[1])
.map((e) => e[0]);
updatePermissions!(selectedPermissions);
}
};
@@ -99,7 +108,7 @@ const SetPermissions: FC<Props> = ({ selectedPermissionsLink }) => {
message = (
<Notification type={"success"} children={t("setPermissions.setPermissionsSuccessful")} onClose={onClose} />
);
} else if (error) {
} else if (permissionsUpdateError) {
message = <ErrorNotification error={error} />;
}
@@ -107,12 +116,12 @@ const SetPermissions: FC<Props> = ({ selectedPermissionsLink }) => {
<form onSubmit={submit}>
<Subtitle subtitle={t("setPermissions.subtitle")} />
{message}
<PermissionsWrapper permissions={permissions} onChange={valueChanged} disabled={!overwritePermissionsLink} />
<PermissionsWrapper permissions={permissions} onChange={valueChanged} disabled={!updatePermissions} />
<Level
right={
<SubmitButton
disabled={!permissionsChanged}
loading={loading}
loading={isUpdatingPermissions}
label={t("setPermissions.button")}
testId="set-permissions-button"
/>
@@ -121,4 +130,5 @@ const SetPermissions: FC<Props> = ({ selectedPermissionsLink }) => {
</form>
);
};
export default SetPermissions;

View File

@@ -21,41 +21,38 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { User } from "@scm-manager/ui-types";
import React, { FC } from "react";
import SetPermissions from "./SetPermissions";
import { useSetUserPermissions, useUserPermissions } from "@scm-manager/ui-api";
import { apiClient } from "@scm-manager/ui-components";
type Props = {
user: User;
};
export const CONTENT_TYPE_PERMISSIONS = "application/vnd.scmm-permissionCollection+json;v=2";
const SetUserPermissions: FC<Props> = ({ user }) => {
const {
data: selectedPermissions,
isLoading: loadingPermissions,
error: permissionsLoadError,
} = useUserPermissions(user);
const {
isLoading: isUpdatingPermissions,
isUpdated: permissionsUpdated,
setPermissions,
error: permissionsUpdateError,
} = useSetUserPermissions(user, selectedPermissions);
return (
<SetPermissions
selectedPermissions={selectedPermissions}
loadingPermissions={loadingPermissions}
isUpdatingPermissions={isUpdatingPermissions}
permissionsLoadError={permissionsLoadError || undefined}
permissionsUpdated={permissionsUpdated}
updatePermissions={setPermissions}
permissionsUpdateError={permissionsUpdateError || undefined}
/>
);
};
export function setPermissions(url: string, permissions: string[]) {
return apiClient
.put(
url,
{
permissions: permissions
},
CONTENT_TYPE_PERMISSIONS
)
.then(response => {
return response;
});
}
export function loadPermissionsForEntity(availableUrl: string, userUrl: string) {
return Promise.all([
apiClient.get(availableUrl).then(response => {
return response.json();
}),
apiClient.get(userUrl).then(response => {
return response.json();
})
]).then(values => {
const [availablePermissions, checkedPermissions] = values;
const permissions = {};
availablePermissions.permissions.forEach(p => (permissions[p] = false));
checkedPermissions.permissions.forEach(p => (permissions[p] = true));
return {
permissions,
overwriteLink: checkedPermissions._links.overwrite
};
});
}
export default SetUserPermissions;

View File

@@ -1,81 +0,0 @@
/*
* 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 fetchMock from "fetch-mock";
import { loadPermissionsForEntity } from "./handlePermissions";
describe("load permissions for entity", () => {
const AVAILABLE_PERMISSIONS_URL = "/permissions";
const USER_PERMISSIONS_URL = "/user/scmadmin/permissions";
const availablePermissions = `{
"permissions": [
"repository:read,pull:*",
"repository:read,pull,push:*",
"repository:*:*"
]
}`;
const userPermissions = `{
"permissions": [
"repository:read,pull:*"
],
"_links": {
"self": {
"href": "/api/v2/users/rene/permissions"
},
"overwrite": {
"href": "/api/v2/users/rene/permissions"
}
}
}`;
beforeEach(() => {
fetchMock.getOnce("/api/v2" + AVAILABLE_PERMISSIONS_URL, availablePermissions);
fetchMock.getOnce("/api/v2" + USER_PERMISSIONS_URL, userPermissions);
});
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should return permissions array", done => {
loadPermissionsForEntity(AVAILABLE_PERMISSIONS_URL, USER_PERMISSIONS_URL).then(result => {
const { permissions } = result;
expect(Object.entries(permissions).length).toBe(3);
expect(permissions["repository:read,pull:*"]).toBe(true);
expect(permissions["repository:read,pull,push:*"]).toBe(false);
expect(permissions["repository:*:*"]).toBe(false);
done();
});
});
it("should return overwrite link", done => {
loadPermissionsForEntity(AVAILABLE_PERMISSIONS_URL, USER_PERMISSIONS_URL).then(result => {
const { overwriteLink } = result;
expect(overwriteLink.href).toBe("/api/v2/users/rene/permissions");
done();
});
});
});

View File

@@ -22,10 +22,11 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { useHistory } from "react-router-dom";
import { Redirect } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Branch, Link, Repository } from "@scm-manager/ui-types";
import { apiClient, ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { Branch, Repository } from "@scm-manager/ui-types";
import { ConfirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components";
import { useDeleteBranch } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
@@ -34,16 +35,12 @@ type Props = {
const DeleteBranch: FC<Props> = ({ repository, branch }: Props) => {
const [showConfirmAlert, setShowConfirmAlert] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [t] = useTranslation("repos");
const history = useHistory();
const { isLoading, error, remove, isDeleted } = useDeleteBranch(repository);
const deleteBranch = () => {
apiClient
.delete((branch._links.delete as Link).href)
.then(() => history.push(`/repo/${repository.namespace}/${repository.name}/branches/`))
.catch(setError);
};
if (isDeleted) {
return <Redirect to={`/repo/${repository.namespace}/${repository.name}/branches/`} />;
}
if (!branch._links.delete) {
return null;
@@ -59,12 +56,13 @@ const DeleteBranch: FC<Props> = ({ repository, branch }: Props) => {
{
className: "is-outlined",
label: t("branch.delete.confirmAlert.submit"),
onClick: () => deleteBranch()
onClick: () => remove(branch),
isLoading,
},
{
label: t("branch.delete.confirmAlert.cancel"),
onClick: () => null
}
onClick: () => null,
},
]}
close={() => setShowConfirmAlert(false)}
/>

View File

@@ -23,8 +23,7 @@
*/
import React, { FC } from "react";
import { FileUpload, LabelWithHelpIcon, Checkbox, InputField } from "@scm-manager/ui-components";
import { File } from "@scm-manager/ui-types";
import { Checkbox, FileUpload, InputField, LabelWithHelpIcon } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
@@ -44,7 +43,7 @@ const ImportFromBundleForm: FC<Props> = ({
setCompressed,
password,
setPassword,
disabled
disabled,
}) => {
const [t] = useTranslation("repos");
@@ -54,7 +53,7 @@ const ImportFromBundleForm: FC<Props> = ({
<div className="column is-half is-vcentered">
<LabelWithHelpIcon label={t("import.bundle.title")} helpText={t("import.bundle.helpText")} />
<FileUpload
handleFile={(file: File) => {
handleFile={(file) => {
setFile(file);
setValid(!!file);
}}
@@ -75,7 +74,7 @@ const ImportFromBundleForm: FC<Props> = ({
<div className="column is-half is-vcentered">
<InputField
value={password}
onChange={value => setPassword(value)}
onChange={(value) => setPassword(value)}
type="password"
label={t("import.bundle.password.title")}
helpText={t("import.bundle.password.helpText")}

View File

@@ -21,77 +21,56 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, FormEvent, useState } from "react";
import { File, RepositoryCreation } from "@scm-manager/ui-types";
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import React, { FC, FormEvent, useEffect, useState } from "react";
import { Repository, RepositoryCreation, RepositoryType } from "@scm-manager/ui-types";
import { ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import ImportFullRepositoryForm from "./ImportFullRepositoryForm";
import { SubFormProps } from "../types";
import { extensionPoints } from "@scm-manager/ui-extensions";
import { useImportFullRepository } from "@scm-manager/ui-api";
type Props = {
url: string;
repositoryType: string;
repositoryType: RepositoryType;
setImportPending: (pending: boolean) => void;
nameForm: React.ComponentType<SubFormProps>;
informationForm: React.ComponentType<SubFormProps>;
setImportedRepository: (repository: Repository) => void;
nameForm: extensionPoints.RepositoryCreatorComponentProps["nameForm"];
informationForm: extensionPoints.RepositoryCreatorComponentProps["informationForm"];
};
const ImportFullRepository: FC<Props> = ({
url,
repositoryType,
setImportPending,
setImportedRepository,
nameForm: NameForm,
informationForm: InformationForm
informationForm: InformationForm,
}) => {
const [repo, setRepo] = useState<RepositoryCreation>({
name: "",
namespace: "",
type: repositoryType,
type: repositoryType.name,
contact: "",
description: "",
contextEntries: []
contextEntries: [],
});
const [password, setPassword] = useState("");
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [file, setFile] = useState<File | null>(null);
const history = useHistory();
const [t] = useTranslation("repos");
const { importFullRepository, importedRepository, isLoading, error } = useImportFullRepository(repositoryType);
const handleImportLoading = (loading: boolean) => {
setImportPending(loading);
setLoading(loading);
};
useEffect(() => setRepo({...repo, type: repositoryType.name}), [repositoryType]);
useEffect(() => setImportPending(isLoading), [isLoading]);
useEffect(() => {
if (importedRepository) {
setImportedRepository(importedRepository);
}
}, [importedRepository]);
const isValid = () => Object.values(valid).every(v => v);
const isValid = () => Object.values(valid).every((v) => v);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const currentPath = history.location.pathname;
setError(undefined);
handleImportLoading(true);
apiClient
.postBinary(url, formData => {
formData.append("bundle", file, file?.name);
formData.append("repository", JSON.stringify({ ...repo, password }));
})
.then(response => {
const location = response.headers.get("Location");
return apiClient.get(location!);
})
.then(response => response.json())
.then(repo => {
handleImportLoading(false);
if (history.location.pathname === currentPath) {
history.push(`/repo/${repo.namespace}/${repo.name}/code/sources`);
}
})
.catch(error => {
setError(error);
handleImportLoading(false);
});
importFullRepository(repo, file!, password);
};
return (
@@ -108,16 +87,16 @@ const ImportFullRepository: FC<Props> = ({
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
disabled={isLoading}
/>
<InformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
disabled={loading}
disabled={isLoading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>
<Level
right={<SubmitButton disabled={!isValid()} loading={loading} label={t("repositoryForm.submitImport")} />}
right={<SubmitButton disabled={!isValid()} loading={isLoading} label={t("repositoryForm.submitImport")} />}
/>
</form>
);

View File

@@ -24,7 +24,6 @@
import React, { FC } from "react";
import { FileUpload, InputField, LabelWithHelpIcon } from "@scm-manager/ui-components";
import { File } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
type Props = {
@@ -51,7 +50,7 @@ const ImportFullRepositoryForm: FC<Props> = ({ setFile, setValid, password, setP
<div className="column is-half is-vcentered">
<InputField
value={password}
onChange={value => setPassword(value)}
onChange={(value) => setPassword(value)}
type="password"
label={t("import.bundle.password.title")}
helpText={t("import.bundle.password.helpText")}

View File

@@ -21,78 +21,58 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, FormEvent, useState } from "react";
import { File, RepositoryCreation } from "@scm-manager/ui-types";
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import React, { FC, FormEvent, useEffect, useState } from "react";
import { Repository, RepositoryCreation, RepositoryType } from "@scm-manager/ui-types";
import { ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import ImportFromBundleForm from "./ImportFromBundleForm";
import { SubFormProps } from "../types";
import { extensionPoints } from "@scm-manager/ui-extensions";
import { useImportRepositoryFromBundle } from "@scm-manager/ui-api";
type Props = {
url: string;
repositoryType: string;
repositoryType: RepositoryType;
setImportPending: (pending: boolean) => void;
nameForm: React.ComponentType<SubFormProps>;
informationForm: React.ComponentType<SubFormProps>;
setImportedRepository: (repository: Repository) => void;
nameForm: extensionPoints.RepositoryCreatorComponentProps["nameForm"];
informationForm: extensionPoints.RepositoryCreatorComponentProps["informationForm"];
};
const ImportRepositoryFromBundle: FC<Props> = ({
url,
repositoryType,
setImportPending,
setImportedRepository,
nameForm: NameForm,
informationForm: InformationForm
informationForm: InformationForm,
}) => {
const [repo, setRepo] = useState<RepositoryCreation>({
name: "",
namespace: "",
type: repositoryType,
type: repositoryType.name,
contact: "",
description: "",
contextEntries: []
contextEntries: [],
});
const [password, setPassword] = useState("");
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [file, setFile] = useState<File | null>(null);
const [compressed, setCompressed] = useState(true);
const history = useHistory();
const [t] = useTranslation("repos");
const { importRepositoryFromBundle, importedRepository, error, isLoading } =
useImportRepositoryFromBundle(repositoryType);
const handleImportLoading = (loading: boolean) => {
setImportPending(loading);
setLoading(loading);
};
useEffect(() => setRepo({...repo, type: repositoryType.name}), [repositoryType]);
useEffect(() => setImportPending(isLoading), [isLoading]);
useEffect(() => {
if (importedRepository) {
setImportedRepository(importedRepository);
}
}, [importedRepository]);
const isValid = () => Object.values(valid).every(v => v);
const isValid = () => Object.values(valid).every((v) => v);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const currentPath = history.location.pathname;
setError(undefined);
handleImportLoading(true);
apiClient
.postBinary(compressed ? url + "?compressed=true" : url, formData => {
formData.append("bundle", file, file?.name);
formData.append("repository", JSON.stringify({ ...repo, password }));
})
.then(response => {
const location = response.headers.get("Location");
return apiClient.get(location!);
})
.then(response => response.json())
.then(repo => {
handleImportLoading(false);
if (history.location.pathname === currentPath) {
history.push(`/repo/${repo.namespace}/${repo.name}/code/sources`);
}
})
.catch(error => {
setError(error);
handleImportLoading(false);
});
importRepositoryFromBundle(repo, file!, compressed, password);
};
return (
@@ -105,23 +85,23 @@ const ImportRepositoryFromBundle: FC<Props> = ({
setCompressed={setCompressed}
password={password}
setPassword={setPassword}
disabled={loading}
disabled={isLoading}
/>
<hr />
<NameForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
disabled={isLoading}
/>
<InformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
disabled={loading}
disabled={isLoading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>
<Level
right={<SubmitButton disabled={!isValid()} loading={loading} label={t("repositoryForm.submitImport")} />}
right={<SubmitButton disabled={!isValid()} loading={isLoading} label={t("repositoryForm.submitImport")} />}
/>
</form>
);

View File

@@ -21,102 +21,84 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, FormEvent, useState } from "react";
import { RepositoryCreation, RepositoryUrlImport } from "@scm-manager/ui-types";
import React, { FC, FormEvent, useEffect, useState } from "react";
import { Repository, RepositoryCreation, RepositoryType, RepositoryUrlImport } from "@scm-manager/ui-types";
import ImportFromUrlForm from "./ImportFromUrlForm";
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { SubFormProps } from "../types";
import { useImportRepositoryFromUrl } from "@scm-manager/ui-api";
import { extensionPoints } from "@scm-manager/ui-extensions";
type Props = {
url: string;
repositoryType: string;
repositoryType: RepositoryType;
setImportPending: (pending: boolean) => void;
nameForm: React.ComponentType<SubFormProps>;
informationForm: React.ComponentType<SubFormProps>;
setImportedRepository: (repository: Repository) => void;
nameForm: extensionPoints.RepositoryCreatorComponentProps["nameForm"];
informationForm: extensionPoints.RepositoryCreatorComponentProps["informationForm"];
};
const ImportRepositoryFromUrl: FC<Props> = ({
url,
repositoryType,
setImportPending,
setImportedRepository,
nameForm: NameForm,
informationForm: InformationForm
informationForm: InformationForm,
}) => {
const [repo, setRepo] = useState<RepositoryUrlImport>({
name: "",
namespace: "",
type: repositoryType,
type: repositoryType.name,
contact: "",
description: "",
importUrl: "",
username: "",
password: "",
contextEntries: []
contextEntries: [],
});
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, importUrl: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const history = useHistory();
const [t] = useTranslation("repos");
const { importRepositoryFromUrl, importedRepository, error, isLoading } = useImportRepositoryFromUrl(repositoryType);
const isValid = () => Object.values(valid).every(v => v);
useEffect(() => setRepo({...repo, type: repositoryType.name}), [repositoryType]);
useEffect(() => setImportPending(isLoading), [isLoading]);
useEffect(() => {
if (importedRepository) {
setImportedRepository(importedRepository);
}
}, [importedRepository]);
const handleImportLoading = (loading: boolean) => {
setImportPending(loading);
setLoading(loading);
};
const isValid = () => Object.values(valid).every((v) => v);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(undefined);
const currentPath = history.location.pathname;
handleImportLoading(true);
apiClient
.post(url, repo, "application/vnd.scmm-repository+json;v=2")
.then(response => {
const location = response.headers.get("Location");
return apiClient.get(location!);
})
.then(response => response.json())
.then(repo => {
handleImportLoading(false);
if (history.location.pathname === currentPath) {
history.push(`/repo/${repo.namespace}/${repo.name}/code/sources`);
}
})
.catch(error => {
setError(error);
handleImportLoading(false);
});
importRepositoryFromUrl(repo);
};
return (
<form onSubmit={submit}>
<ErrorNotification error={error} />
{error ? <ErrorNotification error={error} /> : null}
<ImportFromUrlForm
repository={repo}
onChange={setRepo}
setValid={(importUrl: boolean) => setValid({ ...valid, importUrl })}
disabled={loading}
disabled={isLoading}
/>
<hr />
<NameForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
disabled={isLoading}
/>
<InformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<RepositoryCreation>>}
disabled={loading}
disabled={isLoading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>
<Level
right={<SubmitButton disabled={!isValid()} loading={loading} label={t("repositoryForm.submitImport")} />}
right={<SubmitButton disabled={!isValid()} loading={isLoading} label={t("repositoryForm.submitImport")} />}
/>
</form>
);

View File

@@ -25,12 +25,12 @@ import React, { FC } from "react";
import { Redirect, useRouteMatch } from "react-router-dom";
import RepositoryForm from "../components/form";
import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Notification, Subtitle, urls } from "@scm-manager/ui-components";
import { ErrorNotification, Subtitle, urls } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import RepositoryDangerZone from "./RepositoryDangerZone";
import { useTranslation } from "react-i18next";
import ExportRepository from "./ExportRepository";
import { useIndexLinks, useUpdateRepository } from "@scm-manager/ui-api";
import { useUpdateRepository } from "@scm-manager/ui-api";
import HealthCheckWarning from "./HealthCheckWarning";
import RunHealthCheck from "./RunHealthCheck";
@@ -41,7 +41,6 @@ type Props = {
const EditRepo: FC<Props> = ({ repository }) => {
const match = useRouteMatch();
const { isLoading, error, update, isUpdated } = useUpdateRepository();
const indexLinks = useIndexLinks();
const [t] = useTranslation("repos");
if (isUpdated) {
@@ -51,7 +50,7 @@ const EditRepo: FC<Props> = ({ repository }) => {
const url = urls.matchedUrlFromMatch(match);
const extensionProps = {
repository,
url
url,
};
return (
@@ -66,7 +65,7 @@ const EditRepo: FC<Props> = ({ repository }) => {
{(repository._links.runHealthCheck || repository.healthCheckRunning) && (
<RunHealthCheck repository={repository} />
)}
<RepositoryDangerZone repository={repository} indexLinks={indexLinks} />
<RepositoryDangerZone repository={repository} />
</>
);
};

View File

@@ -22,8 +22,8 @@
* SOFTWARE.
*/
import React, { FC, useState } from "react";
import { Link, RepositoryType } from "@scm-manager/ui-types";
import React, { useState } from "react";
import { Link, Repository, RepositoryType } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
import ImportRepositoryTypeSelect from "../components/ImportRepositoryTypeSelect";
@@ -33,8 +33,8 @@ import { Loading, Notification, useNavigationLock } from "@scm-manager/ui-compon
import ImportRepositoryFromBundle from "../components/ImportRepositoryFromBundle";
import ImportFullRepository from "../components/ImportFullRepository";
import { Prompt } from "react-router-dom";
import { CreatorComponentProps } from "../types";
import { Prompt, Redirect } from "react-router-dom";
import { extensionPoints } from "@scm-manager/ui-extensions";
const ImportPendingLoading = ({ importPending }: { importPending: boolean }) => {
const [t] = useTranslation("repos");
@@ -50,8 +50,13 @@ const ImportPendingLoading = ({ importPending }: { importPending: boolean }) =>
);
};
const ImportRepository: FC<CreatorComponentProps> = ({ repositoryTypes, nameForm, informationForm }) => {
const ImportRepository: extensionPoints.RepositoryCreatorExtension["component"] = ({
repositoryTypes,
nameForm,
informationForm,
}) => {
const [importPending, setImportPending] = useState(false);
const [importedRepository, setImportedRepository] = useState<Repository>();
const [repositoryType, setRepositoryType] = useState<RepositoryType | undefined>();
const [importType, setImportType] = useState("");
const [t] = useTranslation("repos");
@@ -67,9 +72,9 @@ const ImportRepository: FC<CreatorComponentProps> = ({ repositoryTypes, nameForm
if (importType === "url") {
return (
<ImportRepositoryFromUrl
url={((repositoryType!._links.import as Link[])!.find((link: Link) => link.name === "url") as Link).href}
repositoryType={repositoryType!.name}
repositoryType={repositoryType!}
setImportPending={setImportPending}
setImportedRepository={setImportedRepository}
nameForm={nameForm}
informationForm={informationForm}
/>
@@ -79,9 +84,9 @@ const ImportRepository: FC<CreatorComponentProps> = ({ repositoryTypes, nameForm
if (importType === "bundle") {
return (
<ImportRepositoryFromBundle
url={((repositoryType!._links.import as Link[])!.find((link: Link) => link.name === "bundle") as Link).href}
repositoryType={repositoryType!.name}
repositoryType={repositoryType!}
setImportPending={setImportPending}
setImportedRepository={setImportedRepository}
nameForm={nameForm}
informationForm={informationForm}
/>
@@ -91,11 +96,9 @@ const ImportRepository: FC<CreatorComponentProps> = ({ repositoryTypes, nameForm
if (importType === "fullImport") {
return (
<ImportFullRepository
url={
((repositoryType!._links.import as Link[])!.find((link: Link) => link.name === "fullImport") as Link).href
}
repositoryType={repositoryType!.name}
repositoryType={repositoryType!}
setImportPending={setImportPending}
setImportedRepository={setImportedRepository}
nameForm={nameForm}
informationForm={informationForm}
/>
@@ -105,6 +108,10 @@ const ImportRepository: FC<CreatorComponentProps> = ({ repositoryTypes, nameForm
throw new Error("Unknown import type");
};
if (importedRepository) {
return <Redirect to={`/repo/${importedRepository.namespace}/${importedRepository.name}/code/sources`} />;
}
return (
<>
<Prompt when={importPending} message={t("import.navigationWarning")} />

View File

@@ -22,47 +22,42 @@
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { Link, Links, Repository, CUSTOM_NAMESPACE_STRATEGY } from "@scm-manager/ui-types";
import React, { FC, useState } from "react";
import { CUSTOM_NAMESPACE_STRATEGY, Repository } from "@scm-manager/ui-types";
import { Button, ButtonGroup, ErrorNotification, InputField, Level, Loading, Modal } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { apiClient } from "@scm-manager/ui-components";
import { useHistory } from "react-router-dom";
import { Redirect } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import * as validator from "../components/form/repositoryValidation";
export const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
import { useNamespaceStrategies, useRenameRepository } from "@scm-manager/ui-api";
type Props = {
repository: Repository;
indexLinks: Links;
};
const RenameRepository: FC<Props> = ({ repository, indexLinks }) => {
const history = useHistory();
const RenameRepository: FC<Props> = ({ repository }) => {
const [t] = useTranslation("repos");
const [error, setError] = useState<Error | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [name, setName] = useState(repository.name);
const [namespace, setNamespace] = useState(repository.namespace);
const [nameValidationError, setNameValidationError] = useState(false);
const [namespaceValidationError, setNamespaceValidationError] = useState(false);
const [currentNamespaceStrategie, setCurrentNamespaceStrategy] = useState("");
const { isLoading: isRenaming, renameRepository, isRenamed, error: renamingError } = useRenameRepository(repository);
const {
isLoading: isLoadingNamespaceStrategies,
error: namespaceStrategyLoadError,
data: namespaceStrategies,
} = useNamespaceStrategies();
useEffect(() => {
apiClient
.get((indexLinks?.namespaceStrategies as Link).href)
.then(result => result.json())
.then(result => setCurrentNamespaceStrategy(result.current))
.catch(setError);
}, [repository]);
if (error) {
return <ErrorNotification error={error} />;
if (isRenamed) {
return <Redirect to={`/repo/${namespace}/${name}`} />;
}
if (loading) {
if (namespaceStrategyLoadError) {
return <ErrorNotification error={namespaceStrategyLoadError} />;
}
if (isLoadingNamespaceStrategies) {
return <Loading />;
}
@@ -88,31 +83,19 @@ const RenameRepository: FC<Props> = ({ repository, indexLinks }) => {
value: namespace,
onChange: handleNamespaceChange,
errorMessage: t("validation.namespace-invalid"),
validationError: namespaceValidationError
validationError: namespaceValidationError,
};
if (currentNamespaceStrategie === CUSTOM_NAMESPACE_STRATEGY) {
if (namespaceStrategies!.current === CUSTOM_NAMESPACE_STRATEGY) {
return <InputField {...props} />;
}
return <ExtensionPoint name="repos.create.namespace" props={props} renderAll={false} />;
};
const rename = () => {
setLoading(true);
const url = repository?._links?.renameWithNamespace
? (repository?._links?.renameWithNamespace as Link).href
: (repository?._links?.rename as Link).href;
apiClient
.post(url, { name, namespace }, CONTENT_TYPE)
.then(() => setLoading(false))
.then(() => history.push(`/repo/${namespace}/${name}`))
.catch(setError);
};
const modalBody = (
<div>
{renamingError ? <ErrorNotification error={renamingError} /> : null}
<InputField
label={t("renameRepo.modal.label.repoName")}
name={t("renameRepo.modal.label.repoName")}
@@ -135,7 +118,8 @@ const RenameRepository: FC<Props> = ({ repository, indexLinks }) => {
label={t("renameRepo.modal.button.rename")}
disabled={!isValid}
title={t("renameRepo.modal.button.rename")}
action={rename}
loading={isRenaming}
action={() => renameRepository(namespace, name)}
/>
<Button
label={t("renameRepo.modal.button.cancel")}
@@ -165,15 +149,7 @@ const RenameRepository: FC<Props> = ({ repository, indexLinks }) => {
{t("renameRepo.description2")}
</p>
}
right={
<Button
label={t("renameRepo.button")}
action={() => setShowModal(true)}
loading={loading}
color="warning"
icon="edit"
/>
}
right={<Button label={t("renameRepo.button")} action={() => setShowModal(true)} color="warning" icon="edit" />}
/>
</>
);

View File

@@ -23,7 +23,7 @@
*/
import React, { FC } from "react";
import { Repository, Links } from "@scm-manager/ui-types";
import { Repository } from "@scm-manager/ui-types";
import RenameRepository from "./RenameRepository";
import DeleteRepo from "./DeleteRepo";
import styled from "styled-components";
@@ -34,7 +34,6 @@ import UnarchiveRepo from "./UnarchiveRepo";
type Props = {
repository: Repository;
indexLinks: Links;
};
export const DangerZoneContainer = styled.div`
@@ -60,12 +59,12 @@ export const DangerZoneContainer = styled.div`
}
`;
const RepositoryDangerZone: FC<Props> = ({ repository, indexLinks }) => {
const RepositoryDangerZone: FC<Props> = ({ repository }) => {
const [t] = useTranslation("repos");
const dangerZone = [];
if (repository?._links?.rename || repository?._links?.renameWithNamespace) {
dangerZone.push(<RenameRepository repository={repository} indexLinks={indexLinks} />);
dangerZone.push(<RenameRepository repository={repository} />);
}
if (repository?._links?.delete) {
dangerZone.push(<DeleteRepo repository={repository} />);

View File

@@ -21,13 +21,13 @@
* 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 { getContent } from "./SourcecodeViewer";
import { File, Link } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { File } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, MarkdownView } from "@scm-manager/ui-components";
import styled from "styled-components";
import replaceBranchWithRevision from "../../ReplaceBranchWithRevision";
import { useLocation } from "react-router-dom";
import { useFileContent } from "@scm-manager/ui-api";
type Props = {
file: File;
@@ -39,24 +39,10 @@ const MarkdownContent = styled.div`
`;
const MarkdownViewer: FC<Props> = ({ file, basePath }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [content, setContent] = useState("");
const { isLoading, error, data: content } = useFileContent(file);
const location = useLocation();
useEffect(() => {
getContent((file._links.self as Link).href)
.then(content => {
setLoading(false);
setContent(content);
})
.catch(error => {
setLoading(false);
setError(error);
});
}, [file]);
if (loading) {
if (!content || isLoading) {
return <Loading />;
}

View File

@@ -21,11 +21,12 @@
* 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, Loading, SyntaxHighlighter } from "@scm-manager/ui-components";
import { File, Link } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { ErrorNotification, Loading, SyntaxHighlighter } from "@scm-manager/ui-components";
import { File } from "@scm-manager/ui-types";
import { useLocation } from "react-router-dom";
import replaceBranchWithRevision from "../../ReplaceBranchWithRevision";
import { useFileContent } from "@scm-manager/ui-api";
type Props = {
file: File;
@@ -33,47 +34,20 @@ type Props = {
};
const SourcecodeViewer: FC<Props> = ({ file, language }) => {
const [content, setContent] = useState("");
const [error, setError] = useState<Error | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [currentFileRevision, setCurrentFileRevision] = useState("");
const { error, isLoading, data: content } = useFileContent(file);
const location = useLocation();
useEffect(() => {
if (file.revision !== currentFileRevision) {
getContent((file._links.self as Link).href)
.then(content => {
setContent(content);
setCurrentFileRevision(file.revision);
setLoading(false);
})
.catch(setError);
}
}, [currentFileRevision, file]);
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
if (!content || isLoading) {
return <Loading />;
}
if (!content) {
return null;
}
const permalink = replaceBranchWithRevision(location.pathname, file.revision);
const permalink = replaceBranchWithRevision(location.pathname, currentFileRevision);
return <SyntaxHighlighter language={getLanguage(language)} value={content} permalink={permalink} />;
return <SyntaxHighlighter language={language.toLowerCase()} value={content} permalink={permalink} />;
};
export function getLanguage(language: string) {
return language.toLowerCase();
}
export function getContent(url: string) {
return apiClient.get(url).then(response => response.text());
}
export default SourcecodeViewer;

View File

@@ -21,140 +21,68 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC, FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types";
import {
ErrorNotification,
Subtitle,
Level,
Notification,
PasswordConfirmation,
SubmitButton
SubmitButton,
Subtitle,
} from "@scm-manager/ui-components";
import { setPassword } from "./setPassword";
import { useSetUserPassword } from "@scm-manager/ui-api";
type Props = WithTranslation & {
type Props = {
user: User;
};
type State = {
password: string;
loading: boolean;
error?: Error;
passwordChanged: boolean;
passwordValid: boolean;
const SetUserPassword: FC<Props> = ({ user }) => {
const [t] = useTranslation("users");
const { passwordOverwritten, setPassword, error, isLoading, reset } = useSetUserPassword(user);
const [newPassword, setNewPassword] = useState("");
const [passwordValid, setPasswordValid] = useState(false);
useEffect(() => {
if (passwordOverwritten) {
setNewPassword("");
setPasswordValid(false);
}
}, [passwordOverwritten]);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (newPassword) {
setPassword(newPassword);
}
};
const onPasswordChange = (newValue: string, valid: boolean) => {
setNewPassword(newValue);
setPasswordValid(!!newValue && valid);
};
let message;
if (passwordOverwritten) {
message = (
<Notification type={"success"} children={t("singleUserPassword.setPasswordSuccessful")} onClose={reset} />
);
} else if (error) {
message = <ErrorNotification error={error} />;
}
return (
<form onSubmit={submit}>
<Subtitle subtitle={t("singleUserPassword.subtitle")} />
{message}
<PasswordConfirmation passwordChanged={onPasswordChange} key={passwordOverwritten ? "changed" : "unchanged"} />
<Level
right={<SubmitButton disabled={!passwordValid} loading={isLoading} label={t("singleUserPassword.button")} />}
/>
</form>
);
};
class SetUserPassword extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
password: "",
loading: false,
passwordChanged: false,
passwordValid: false
};
}
setLoadingState = () => {
this.setState({
...this.state,
loading: true
});
};
setErrorState = (error: Error) => {
this.setState({
...this.state,
error: error,
loading: false
});
};
setSuccessfulState = () => {
this.setState({
...this.state,
loading: false,
passwordChanged: true,
password: ""
});
};
submit = (event: Event) => {
event.preventDefault();
if (this.state.password) {
const { user } = this.props;
const { password } = this.state;
this.setLoadingState();
setPassword(user._links.password.href, password)
.then((result) => {
if (result.error) {
this.setErrorState(result.error);
} else {
this.setSuccessfulState();
}
})
.catch((err) => {
this.setErrorState(err);
});
}
};
render() {
const { t } = this.props;
const { loading, passwordChanged, error } = this.state;
let message = null;
if (passwordChanged) {
message = (
<Notification
type={"success"}
children={t("singleUserPassword.setPasswordSuccessful")}
onClose={() => this.onClose()}
/>
);
} else if (error) {
message = <ErrorNotification error={error} />;
}
return (
<form onSubmit={this.submit}>
<Subtitle subtitle={t("singleUserPassword.subtitle")} />
{message}
<PasswordConfirmation
passwordChanged={this.passwordChanged}
key={this.state.passwordChanged ? "changed" : "unchanged"}
/>
<Level
right={
<SubmitButton
disabled={!this.state.passwordValid}
loading={loading}
label={t("singleUserPassword.button")}
/>
}
/>
</form>
);
}
passwordChanged = (password: string, passwordValid: boolean) => {
this.setState({
...this.state,
password,
passwordValid: !!password && passwordValid
});
};
onClose = () => {
this.setState({
...this.state,
passwordChanged: false
});
};
}
export default withTranslation("users")(SetUserPassword);
export default SetUserPassword;

View File

@@ -23,40 +23,34 @@
*/
import React, { FC, useState } from "react";
import {
apiClient,
ErrorNotification,
InputField,
Level,
Loading,
SubmitButton,
Subtitle
} from "@scm-manager/ui-components";
import { ErrorNotification, InputField, Level, Loading, SubmitButton, Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { CONTENT_TYPE_API_KEY } from "./SetApiKeys";
import RoleSelector from "../../../repos/permissions/components/RoleSelector";
import ApiKeyCreatedModal from "./ApiKeyCreatedModal";
import { useRepositoryRoles } from "@scm-manager/ui-api";
import { useCreateApiKey, useRepositoryRoles } from "@scm-manager/ui-api";
import { ApiKeysCollection, Me, User } from "@scm-manager/ui-types";
type Props = {
createLink: string;
refresh: () => void;
user: User | Me;
apiKeys: ApiKeysCollection;
};
const AddApiKey: FC<Props> = ({ createLink, refresh }) => {
const AddApiKey: FC<Props> = ({ user, apiKeys }) => {
const [t] = useTranslation("users");
const [isCurrentlyAddingKey, setCurrentlyAddingKey] = useState(false);
const [errorAddingKey, setErrorAddingKey] = useState<undefined | Error>();
const {
isLoading: isCurrentlyAddingKey,
error: errorAddingKey,
apiKey: addedKey,
create,
reset: resetCreationHook,
} = useCreateApiKey(user, apiKeys);
const [displayName, setDisplayName] = useState("");
const [permissionRole, setPermissionRole] = useState("");
const [addedKey, setAddedKey] = useState("");
const {
isLoading: isLoadingRepositoryRoles,
data: availableRepositoryRoles,
error: errorLoadingRepositoryRoles
error: errorLoadingRepositoryRoles,
} = useRepositoryRoles();
const loading = isCurrentlyAddingKey || isLoadingRepositoryRoles;
const error = errorAddingKey || errorLoadingRepositoryRoles;
const isValid = () => {
return !!displayName && !!permissionRole;
@@ -67,32 +61,21 @@ const AddApiKey: FC<Props> = ({ createLink, refresh }) => {
setPermissionRole("");
};
const addKey = () => {
setCurrentlyAddingKey(true);
apiClient
.post(createLink, { displayName: displayName, permissionRole: permissionRole }, CONTENT_TYPE_API_KEY)
.then(response => response.text())
.then(setAddedKey)
.then(() => setCurrentlyAddingKey(false))
.catch(setErrorAddingKey);
};
if (error) {
return <ErrorNotification error={error} />;
if (errorLoadingRepositoryRoles) {
return <ErrorNotification error={errorLoadingRepositoryRoles} />;
}
if (loading) {
if (isLoadingRepositoryRoles) {
return <Loading />;
}
const availableRoleNames = availableRepositoryRoles
? availableRepositoryRoles._embedded.repositoryRoles.map(r => r.name)
? availableRepositoryRoles._embedded.repositoryRoles.map((r) => r.name)
: [];
const closeModal = () => {
resetForm();
refresh();
setAddedKey("");
resetCreationHook();
};
const newKeyModal = addedKey && <ApiKeyCreatedModal addedKey={addedKey} close={closeModal} />;
@@ -100,6 +83,7 @@ const AddApiKey: FC<Props> = ({ createLink, refresh }) => {
return (
<>
<hr />
{errorAddingKey ? <ErrorNotification error={errorAddingKey} /> : null}
<Subtitle subtitle={t("apiKey.addSubtitle")} />
{newKeyModal}
<InputField label={t("apiKey.displayName")} value={displayName} onChange={setDisplayName} />
@@ -112,7 +96,14 @@ const AddApiKey: FC<Props> = ({ createLink, refresh }) => {
role={permissionRole}
/>
<Level
right={<SubmitButton label={t("apiKey.addKey")} loading={loading} disabled={!isValid()} action={addKey} />}
right={
<SubmitButton
label={t("apiKey.addKey")}
loading={isCurrentlyAddingKey}
disabled={!isValid() || isCurrentlyAddingKey}
action={() => create({ displayName, permissionRole })}
/>
}
/>
</>
);

View File

@@ -26,9 +26,10 @@ 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";
import { ApiKeyWithToken } from "@scm-manager/ui-types";
type Props = {
addedKey: string;
addedKey: ApiKeyWithToken;
close: () => void;
};
@@ -46,10 +47,10 @@ const NoLeftMargin = styled.div`
const ApiKeyCreatedModal: FC<Props> = ({ addedKey, close }) => {
const [t] = useTranslation("users");
const [copied, setCopied] = useState(false);
const keyRef = useRef(null);
const keyRef = useRef<HTMLTextAreaElement>(null);
const copy = () => {
keyRef.current.select();
keyRef.current!.select();
document.execCommand("copy");
setCopied(true);
};
@@ -63,10 +64,15 @@ const ApiKeyCreatedModal: FC<Props> = ({ addedKey, close }) => {
<hr />
<div className={"columns"}>
<div className={"column is-11"}>
<KeyArea wrap={"soft"} ref={keyRef} className={"input"} value={addedKey} />
<KeyArea wrap={"soft"} ref={keyRef} className={"input"} value={addedKey.token} />
</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} />
<Icon
className={"is-hidden-mobile fa-2x"}
name={copied ? "clipboard-check" : "clipboard"}
title={t("apiKey.modal.clipboard")}
onClick={copy}
/>
</NoLeftMargin>
</div>
</div>

View File

@@ -24,13 +24,13 @@
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 { ApiKey } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next";
import { DeleteFunction } from "@scm-manager/ui-api";
type Props = {
apiKey: ApiKey;
onDelete: (link: string) => void;
onDelete: DeleteFunction<ApiKey>;
};
export const ApiKeyEntry: FC<Props> = ({ apiKey, onDelete }) => {
@@ -38,7 +38,7 @@ export const ApiKeyEntry: FC<Props> = ({ apiKey, onDelete }) => {
let deleteButton;
if (apiKey?._links?.delete) {
deleteButton = (
<a className="level-item" onClick={() => onDelete((apiKey._links.delete as Link).href)}>
<a className="level-item" onClick={() => onDelete(apiKey)}>
<span className="icon">
<Icon name="trash" title={t("apiKey.delete")} color="inherit" />
</span>

View File

@@ -24,13 +24,14 @@
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";
import { ApiKey, ApiKeysCollection } from "@scm-manager/ui-types";
import { DeleteFunction } from "@scm-manager/ui-api";
type Props = {
apiKeys?: ApiKeysCollection;
onDelete: (link: string) => void;
onDelete: DeleteFunction<ApiKey>;
};
const ApiKeyTable: FC<Props> = ({ apiKeys, onDelete }) => {

View File

@@ -22,29 +22,14 @@
* 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, Subtitle } from "@scm-manager/ui-components";
import { Link, Me, User } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
import ApiKeyTable from "./ApiKeyTable";
import AddApiKey from "./AddApiKey";
import { useTranslation } from "react-i18next";
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";
import { useApiKeys, useDeleteApiKey } from "@scm-manager/ui-api";
import { Link as RouterLink } from "react-router-dom";
type Props = {
user: User | Me;
@@ -52,30 +37,9 @@ type Props = {
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 { isLoading, data: apiKeys, error: fetchError } = useApiKeys(user);
const { error: deletionError, remove } = useDeleteApiKey(user);
const error = deletionError || fetchError;
const createLink = (apiKeys?._links?.create as Link)?.href;
@@ -83,7 +47,7 @@ const SetApiKeys: FC<Props> = ({ user }) => {
return <ErrorNotification error={error} />;
}
if (loading) {
if (!apiKeys || isLoading) {
return <Loading />;
}
@@ -91,12 +55,12 @@ const SetApiKeys: FC<Props> = ({ user }) => {
<>
<Subtitle subtitle={t("apiKey.subtitle")} />
<p>
{t("apiKey.text1")} <Link to={"/admin/roles/"}>{t("apiKey.manageRoles")}</Link>
{t("apiKey.text1")} <RouterLink to={"/admin/roles/"}>{t("apiKey.manageRoles")}</RouterLink>
</p>
<p>{t("apiKey.text2")}</p>
<br />
<ApiKeyTable apiKeys={apiKeys} onDelete={onDelete} />
{createLink && <AddApiKey createLink={createLink} refresh={fetchApiKeys} />}
<ApiKeyTable apiKeys={apiKeys} onDelete={remove} />
{createLink && <AddApiKey user={user} apiKeys={apiKeys} />}
</>
);
};

View File

@@ -23,28 +23,19 @@
*/
import React, { FC, useState } from "react";
import {
ErrorNotification,
InputField,
Level,
Textarea,
SubmitButton,
apiClient,
Loading,
Subtitle
} from "@scm-manager/ui-components";
import { ErrorNotification, InputField, Level, SubmitButton, Subtitle, Textarea } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
import { CONTENT_TYPE_PUBLIC_KEY } from "./SetPublicKeys";
import { Me, PublicKeysCollection, User } from "@scm-manager/ui-types";
import { useCreatePublicKey } from "@scm-manager/ui-api";
type Props = {
createLink: string;
refresh: () => void;
publicKeys: PublicKeysCollection;
user: User | Me;
};
const AddPublicKey: FC<Props> = ({ createLink, refresh }) => {
const AddPublicKey: FC<Props> = ({ user, publicKeys }) => {
const [t] = useTranslation("users");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<undefined | Error>();
const { isLoading, error, create } = useCreatePublicKey(user, publicKeys);
const [displayName, setDisplayName] = useState("");
const [raw, setRaw] = useState("");
@@ -58,31 +49,26 @@ const AddPublicKey: FC<Props> = ({ createLink, refresh }) => {
};
const addKey = () => {
setLoading(true);
apiClient
.post(createLink, { displayName: displayName, raw: raw }, CONTENT_TYPE_PUBLIC_KEY)
.then(resetForm)
.then(refresh)
.then(() => setLoading(false))
.catch(setError);
create({ raw, displayName });
resetForm();
};
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return (
<>
<hr />
{error ? <ErrorNotification error={error} /> : null}
<Subtitle subtitle={t("publicKey.addSubtitle")} />
<InputField label={t("publicKey.displayName")} value={displayName} onChange={setDisplayName} />
<Textarea name="raw" label={t("publicKey.raw")} value={raw} onChange={setRaw} />
<Level
right={<SubmitButton label={t("publicKey.addKey")} loading={loading} disabled={!isValid()} action={addKey} />}
right={
<SubmitButton
label={t("publicKey.addKey")}
loading={isLoading}
disabled={!isValid() || isLoading}
action={addKey}
/>
}
/>
</>
);

View File

@@ -24,13 +24,13 @@
import React, { FC } from "react";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
import { PublicKey } from "./SetPublicKeys";
import { useTranslation } from "react-i18next";
import { Link } from "@scm-manager/ui-types";
import { Link, PublicKey } from "@scm-manager/ui-types";
import { DeleteFunction } from "@scm-manager/ui-api";
type Props = {
publicKey: PublicKey;
onDelete: (link: string) => void;
onDelete: DeleteFunction<PublicKey>;
};
export const PublicKeyEntry: FC<Props> = ({ publicKey, onDelete }) => {
@@ -39,7 +39,7 @@ export const PublicKeyEntry: FC<Props> = ({ publicKey, onDelete }) => {
let deleteButton;
if (publicKey?._links?.delete) {
deleteButton = (
<a className="level-item" onClick={() => onDelete((publicKey._links.delete as Link).href)}>
<a className="level-item" onClick={() => onDelete(publicKey)}>
<span className="icon">
<Icon name="trash" title={t("publicKey.delete")} color="inherit" />
</span>

View File

@@ -24,13 +24,14 @@
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { PublicKey, PublicKeysCollection } from "./SetPublicKeys";
import PublicKeyEntry from "./PublicKeyEntry";
import { Notification } from "@scm-manager/ui-components";
import { DeleteFunction } from "@scm-manager/ui-api";
import { PublicKey, PublicKeysCollection } from "@scm-manager/ui-types";
type Props = {
publicKeys?: PublicKeysCollection;
onDelete: (link: string) => void;
onDelete: DeleteFunction<PublicKey>;
};
const PublicKeyTable: FC<Props> = ({ publicKeys, onDelete }) => {
@@ -51,9 +52,9 @@ const PublicKeyTable: FC<Props> = ({ publicKeys, onDelete }) => {
</tr>
</thead>
<tbody>
{publicKeys?._embedded?.keys?.map((publicKey: PublicKey, index: number) => {
return <PublicKeyEntry key={index} onDelete={onDelete} publicKey={publicKey} />;
})}
{publicKeys?._embedded?.keys?.map((publicKey: PublicKey, index: number) => (
<PublicKeyEntry key={index} onDelete={onDelete} publicKey={publicKey} />
))}
</tbody>
</table>
);

View File

@@ -22,28 +22,13 @@
* SOFTWARE.
*/
import { Collection, Link, Links, User, Me } from "@scm-manager/ui-types";
import React, { FC, useEffect, useState } from "react";
import { Link, Me, User } from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import AddPublicKey from "./AddPublicKey";
import PublicKeyTable from "./PublicKeyTable";
import { apiClient, ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
export type PublicKeysCollection = Collection & {
_embedded: {
keys: PublicKey[];
};
};
export type PublicKey = {
id: string;
displayName: string;
raw: string;
created?: string;
_links: Links;
};
export const CONTENT_TYPE_PUBLIC_KEY = "application/vnd.scmm-publicKey+json;v=2";
import { ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
import { useDeletePublicKey, usePublicKeys } from "@scm-manager/ui-api";
type Props = {
user: User | Me;
@@ -51,30 +36,9 @@ type Props = {
const SetPublicKeys: FC<Props> = ({ user }) => {
const [t] = useTranslation("users");
const [error, setError] = useState<undefined | Error>();
const [loading, setLoading] = useState(false);
const [publicKeys, setPublicKeys] = useState<PublicKeysCollection | undefined>(undefined);
useEffect(() => {
fetchPublicKeys();
}, [user]);
const fetchPublicKeys = () => {
setLoading(true);
apiClient
.get((user._links.publicKeys as Link).href)
.then(r => r.json())
.then(setPublicKeys)
.then(() => setLoading(false))
.catch(setError);
};
const onDelete = (link: string) => {
apiClient
.delete(link)
.then(fetchPublicKeys)
.catch(setError);
};
const { error: fetchingError, isLoading, data: publicKeys } = usePublicKeys(user);
const { error: deletionError, remove } = useDeletePublicKey(user);
const error = fetchingError || deletionError;
const createLink = (publicKeys?._links?.create as Link)?.href;
@@ -82,7 +46,7 @@ const SetPublicKeys: FC<Props> = ({ user }) => {
return <ErrorNotification error={error} />;
}
if (loading) {
if (!publicKeys || isLoading) {
return <Loading />;
}
@@ -91,8 +55,8 @@ const SetPublicKeys: FC<Props> = ({ user }) => {
<Subtitle subtitle={t("publicKey.subtitle")} />
<p>{t("publicKey.description")}</p>
<br />
<PublicKeyTable publicKeys={publicKeys} onDelete={onDelete} />
{createLink && <AddPublicKey createLink={createLink} refresh={fetchPublicKeys} />}
<PublicKeyTable publicKeys={publicKeys} onDelete={remove} />
{createLink && <AddPublicKey publicKeys={publicKeys} user={user} />}
</>
);
};

View File

@@ -24,7 +24,6 @@
import React, { FC } from "react";
import { Route, useParams, useRouteMatch } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Link } from "@scm-manager/ui-types";
import {
CustomQueryFlexWrappedColumns,
ErrorPage,
@@ -36,7 +35,7 @@ import {
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation,
urls
urls,
} from "@scm-manager/ui-components";
import { Details } from "./../components/table";
import EditUser from "./EditUser";
@@ -45,14 +44,14 @@ import {
SetApiKeysNavLink,
SetPasswordNavLink,
SetPermissionsNavLink,
SetPublicKeysNavLink
SetPublicKeysNavLink,
} from "./../components/navLinks";
import { useTranslation } from "react-i18next";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
import SetApiKeys from "../components/apiKeys/SetApiKeys";
import { useUser } from "@scm-manager/ui-api";
import SetUserPermissions from "../../permissions/components/SetUserPermissions";
const SingleUser: FC = () => {
const [t] = useTranslation("users");
@@ -72,7 +71,7 @@ const SingleUser: FC = () => {
const extensionProps = {
user,
url
url,
};
return (
@@ -90,7 +89,7 @@ const SingleUser: FC = () => {
<SetUserPassword user={user} />
</Route>
<Route path={`${url}/settings/permissions`}>
<SetPermissions selectedPermissionsLink={user._links.permissions as Link} />
<SetUserPermissions user={user} />
</Route>
<Route path={`${url}/settings/publickeys`}>
<SetPublicKeys user={user} />