diff --git a/scm-ui/ui-api/src/apiKeys.ts b/scm-ui/ui-api/src/apiKeys.ts new file mode 100644 index 0000000000..a2dca30077 --- /dev/null +++ b/scm-ui/ui-api/src/apiKeys.ts @@ -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 => + useQuery(["user", user.name, "apiKeys"], () => apiClient.get(requiredLink(user, "apiKeys")).then((r) => r.json())); + +const createApiKey = + (link: string) => + async (key: ApiKeyCreation): Promise => { + 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([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( + 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( + (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, + }; +}; diff --git a/scm-ui/ui-api/src/base.ts b/scm-ui/ui-api/src/base.ts index 3f9c93df56..c08320d82c 100644 --- a/scm-ui/ui-api/src/base.ts +++ b/scm-ui/ui-api/src/base.ts @@ -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 = { isLoading: boolean; error: Error | null; data?: T; }; +export type DeleteFunction = (entity: T) => void; export const useIndex = (): ApiResult => { const legacy = useLegacyContext(); - return useQuery("index", () => apiClient.get("/").then(response => response.json()), { - onSuccess: index => { + return useQuery("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 => { // 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 = (name: string): ApiResult => { const link = useIndexLink(name); - return useQuery(name, () => apiClient.get(link!).then(response => response.json()), { - enabled: !!link + return useQuery(name, () => apiClient.get(link!).then((response) => response.json()), { + enabled: !!link, }); }; + +export const useJsonResource = (entity: HalRepresentation, name: string, key: string[]): ApiResult => + useQuery(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(); diff --git a/scm-ui/ui-api/src/branches.test.ts b/scm-ui/ui-api/src/branches.test.ts index 5990f02e30..8acbadf397 100644 --- a/scm-ui/ui-api/src/branches.test.ts +++ b/scm-ui/ui-api/src/branches.test.ts @@ -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 () => { diff --git a/scm-ui/ui-api/src/branches.ts b/scm-ui/ui-api/src/branches.ts index b3bc240fc9..e65e9aa007 100644 --- a/scm-ui/ui-api/src/branches.ts +++ b/scm-ui/ui-api/src/branches.ts @@ -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")); } } diff --git a/scm-ui/ui-webapp/src/utils/changePassword.ts b/scm-ui/ui-api/src/fileContent.ts similarity index 72% rename from scm-ui/ui-webapp/src/utils/changePassword.ts rename to scm-ui/ui-api/src/fileContent.ts index 3b4508cd0c..43aa4306f6 100644 --- a/scm-ui/ui-webapp/src/utils/changePassword.ts +++ b/scm-ui/ui-api/src/fileContent.ts @@ -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 => { + const selfLink = requiredLink(file, "self"); + return useQuery(["fileContent", selfLink], () => apiClient.get(selfLink).then((response) => response.text())); +}; diff --git a/scm-ui/ui-api/src/import.ts b/scm-ui/ui-api/src/import.ts index f9c4e531e9..b0cd8f4589 100644 --- a/scm-ui/ui-api/src/import.ts +++ b/scm-ui/ui-api/src/import.ts @@ -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 => { +export const useImportLog = (logId: string): ApiResult => { const link = useRequiredIndexLink("importLog").replace("{logId}", logId); - return useQuery(["importLog", logId], () => apiClient.get(link).then(response => response.text())); -} + return useQuery(["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((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, 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, 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, + }; +}; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 9a46f03692..3a007af22c 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -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"; diff --git a/scm-ui/ui-api/src/links.test.ts b/scm-ui/ui-api/src/links.test.ts index ede079ba43..26c1b75d2e 100644 --- a/scm-ui/ui-api/src/links.test.ts +++ b/scm-ui/ui-api/src/links.test.ts @@ -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(); + }); }); diff --git a/scm-ui/ui-api/src/links.ts b/scm-ui/ui-api/src/links.ts index da9c03bb5d..e9c2318303 100644 --- a/scm-ui/ui-api/src/links.ts +++ b/scm-ui/ui-api/src/links.ts @@ -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`); } diff --git a/scm-ui/ui-api/src/permissions.ts b/scm-ui/ui-api/src/permissions.ts index 3213cf66ce..1ab7ea46f3 100644 --- a/scm-ui/ui-api/src/permissions.ts +++ b/scm-ui/ui-api/src/permissions.ts @@ -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 => { return useIndexJsonResource("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 => { const link = requiredLink(namespaceOrRepository, "permissions"); const queryKey = createQueryKey(namespaceOrRepository); - return useQuery(queryKey, () => apiClient.get(link).then(response => response.json())); + return useQuery(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( - 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( - 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(group, "permissions", groupPermissionsKey(group)); +export const useUserPermissions = (user: User) => + useJsonResource(user, "permissions", userPermissionsKey(user)); +export const useAvailableGlobalPermissions = () => + useIndexJsonResource>("permissions"); + +const useSetEntityPermissions = (permissionCollection?: GlobalPermissionsCollection, key?: string[]) => { + const queryClient = useQueryClient(); + const url = permissionCollection ? objectLink(permissionCollection, "overwrite") : null; + const { isLoading, error, mutate, data } = useMutation( + (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)); diff --git a/scm-ui/ui-api/src/publicKeys.ts b/scm-ui/ui-api/src/publicKeys.ts new file mode 100644 index 0000000000..15f67cbc5b --- /dev/null +++ b/scm-ui/ui-api/src/publicKeys.ts @@ -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 => + useQuery(["user", user.name, "publicKeys"], () => + apiClient.get(requiredLink(user, "publicKeys")).then((r) => r.json()) + ); + +const createPublicKey = + (link: string) => + async (key: PublicKeyCreation): Promise => { + 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( + 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( + (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, + }; +}; diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts index 63506740bf..64076629dd 100644 --- a/scm-ui/ui-api/src/repositories.ts +++ b/scm-ui/ui-api/src/repositories.ts @@ -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 = { - sortBy: "namespaceAndName" + sortBy: "namespaceAndName", }; if (request?.search) { queryParams.q = request.search; @@ -66,7 +66,7 @@ export const useRepositories = (request?: UseRepositoriesRequest): ApiResult( ["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 { 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( 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 => { const link = useRequiredIndexLink("repositories"); return useQuery(["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( - 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( - 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( - 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( - 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( - 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 => //TODO Refetch while exporting to update the page const { isLoading, error, data } = useQuery( ["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 => { const link = requiredLink(repository, "paths").replace("{revision}", revision); return useQuery(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( + ({ 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, + }; +}; diff --git a/scm-ui/ui-api/src/users.ts b/scm-ui/ui-api/src/users.ts index a8f1dfdb6e..c3202a3c4e 100644 --- a/scm-ui/ui-api/src/users.ts +++ b/scm-ui/ui-api/src/users.ts @@ -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 = return useQuery( ["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 = export const useUser = (name: string): ApiResult => { const indexLink = useRequiredIndexLink("users"); return useQuery(["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(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( - 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( - 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( - 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((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((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 }; }; diff --git a/scm-ui/ui-components/src/forms/FileInput.tsx b/scm-ui/ui-components/src/forms/FileInput.tsx index d257bef7d8..be1eab66da 100644 --- a/scm-ui/ui-components/src/forms/FileInput.tsx +++ b/scm-ui/ui-components/src/forms/FileInput.tsx @@ -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"; diff --git a/scm-ui/ui-components/src/forms/FileUpload.tsx b/scm-ui/ui-components/src/forms/FileUpload.tsx index 48f53b5e0c..b960eeb817 100644 --- a/scm-ui/ui-components/src/forms/FileUpload.tsx +++ b/scm-ui/ui-components/src/forms/FileUpload.tsx @@ -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) => void; diff --git a/scm-ui/ui-webapp/src/users/components/setPassword.test.ts b/scm-ui/ui-types/src/ApiKeys.ts similarity index 66% rename from scm-ui/ui-webapp/src/users/components/setPassword.test.ts rename to scm-ui/ui-types/src/ApiKeys.ts index 599d13c855..2d9e9d2f35 100644 --- a/scm-ui/ui-webapp/src/users/components/setPassword.test.ts +++ b/scm-ui/ui-types/src/ApiKeys.ts @@ -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; diff --git a/scm-ui/ui-webapp/src/utils/changePassword.test.ts b/scm-ui/ui-types/src/GlobalPermissions.ts similarity index 64% rename from scm-ui/ui-webapp/src/utils/changePassword.test.ts rename to scm-ui/ui-types/src/GlobalPermissions.ts index df02df5aec..41ef1fdd98 100644 --- a/scm-ui/ui-webapp/src/utils/changePassword.test.ts +++ b/scm-ui/ui-types/src/GlobalPermissions.ts @@ -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[] }; diff --git a/scm-ui/ui-webapp/src/users/components/setPassword.ts b/scm-ui/ui-types/src/PublicKeys.ts similarity index 74% rename from scm-ui/ui-webapp/src/users/components/setPassword.ts rename to scm-ui/ui-types/src/PublicKeys.ts index 10638bd73b..7457fb3034 100644 --- a/scm-ui/ui-webapp/src/users/components/setPassword.ts +++ b/scm-ui/ui-types/src/PublicKeys.ts @@ -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; diff --git a/scm-ui/ui-types/src/RepositoryPermissions.ts b/scm-ui/ui-types/src/RepositoryPermissions.ts index 04cb63c198..e3eaef831b 100644 --- a/scm-ui/ui-types/src/RepositoryPermissions.ts +++ b/scm-ui/ui-types/src/RepositoryPermissions.ts @@ -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[]; diff --git a/scm-ui/ui-types/src/index.ts b/scm-ui/ui-types/src/index.ts index 8b95eefd89..84a7e872b2 100644 --- a/scm-ui/ui-types/src/index.ts +++ b/scm-ui/ui-types/src/index.ts @@ -67,3 +67,6 @@ export * from "./Admin"; export * from "./Diff"; export * from "./Notifications"; +export * from "./ApiKeys"; +export * from "./PublicKeys"; +export * from "./GlobalPermissions"; diff --git a/scm-ui/ui-webapp/src/containers/ChangeUserPassword.tsx b/scm-ui/ui-webapp/src/containers/ChangeUserPassword.tsx index 4e850f09d8..d30e0af69d 100644 --- a/scm-ui/ui-webapp/src/containers/ChangeUserPassword.tsx +++ b/scm-ui/ui-webapp/src/containers/ChangeUserPassword.tsx @@ -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 = ({ 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) => { + event.preventDefault(); + if (newPassword) { + changePassword(oldPassword, newPassword); + } + }; + + const onPasswordChange = (newValue: string, valid: boolean) => { + setNewPassword(newValue); + setPasswordValid(!!newValue && valid); + }; + + let message = null; + + if (passwordChanged) { + message = ; + } else if (error) { + message = ; + } + + return ( +
+ + {message} +
+
+ +
+
+ + + } + /> + + ); }; -class ChangeUserPassword extends React.Component { - 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 = ( - this.onClose()} /> - ); - } else if (error) { - message = ; - } - - return ( -
- - {message} -
-
- - this.setState({ - ...this.state, - oldPassword - }) - } - value={this.state.oldPassword ? this.state.oldPassword : ""} - helpText={t("password.currentPasswordHelpText")} - /> -
-
- - } /> - - ); - } - - 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; diff --git a/scm-ui/ui-webapp/src/containers/Profile.tsx b/scm-ui/ui-webapp/src/containers/Profile.tsx index 5fa2807063..566db01041 100644 --- a/scm-ui/ui-webapp/src/containers/Profile.tsx +++ b/scm-ui/ui-webapp/src/containers/Profile.tsx @@ -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 = () => { )} {mayChangePassword && ( - } /> + + + )} {canManagePublicKeys && ( - } /> + + + + )} + {canManageApiKeys && ( + + + )} - {canManageApiKeys && } />} diff --git a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx index 5f995c5821..983708e315 100644 --- a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx @@ -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 = () => { - + diff --git a/scm-ui/ui-webapp/src/repos/sources/components/content/SourcecodeViewer.test.ts b/scm-ui/ui-webapp/src/permissions/components/SetGroupPermissions.tsx similarity index 51% rename from scm-ui/ui-webapp/src/repos/sources/components/content/SourcecodeViewer.test.ts rename to scm-ui/ui-webapp/src/permissions/components/SetGroupPermissions.tsx index 00ecbad31a..5e3e3b64d8 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/content/SourcecodeViewer.test.ts +++ b/scm-ui/ui-webapp/src/permissions/components/SetGroupPermissions.tsx @@ -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 = ({ group }) => { + const { + data: selectedPermissions, + isLoading: loadingPermissions, + error: permissionsLoadError, + } = useGroupPermissions(group); + const { + isLoading: isUpdatingPermissions, + isUpdated: permissionsUpdated, + setPermissions, + error: permissionsUpdateError, + } = useSetGroupPermissions(group, selectedPermissions); + return ( + + ); +}; - 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; diff --git a/scm-ui/ui-webapp/src/permissions/components/SetPermissions.tsx b/scm-ui/ui-webapp/src/permissions/components/SetPermissions.tsx index 98367ee0e5..41116af471 100644 --- a/scm-ui/ui-webapp/src/permissions/components/SetPermissions.tsx +++ b/scm-ui/ui-webapp/src/permissions/components/SetPermissions.tsx @@ -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 = ({ selectedPermissionsLink }) => { +const SetPermissions: FC = ({ + loadingPermissions, + isUpdatingPermissions, + permissionsLoadError, + permissionsUpdateError, + updatePermissions, + permissionsUpdated, + selectedPermissions, +}) => { const [t] = useTranslation("permissions"); - const availablePermissionLink = useRequiredIndexLink("permissions"); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(); + const { + data: availablePermissions, + error: availablePermissionsLoadError, + isLoading: isLoadingAvailablePermissions, + } = useAvailableGlobalPermissions(); + const [permissions, setPermissions] = useState>({}); const [permissionsSubmitted, setPermissionsSubmitted] = useState(false); const [permissionsChanged, setPermissionsChanged] = useState(false); - const [overwritePermissionsLink, setOverwritePermissionsLink] = useState(); - 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 = {}; + 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 ; + } - const setSuccessfulState = () => { - setLoading(false); - setError(undefined); - setPermissionsSubmitted(true); - setPermissionsChanged(false); - }; + if (error) { + return ; + } const valueChanged = (value: boolean, name: string) => { setPermissions({ ...permissions, - [name]: value + [name]: value, }); setPermissionsChanged(true); }; @@ -81,15 +95,10 @@ const SetPermissions: FC = ({ selectedPermissionsLink }) => { const submit = (event: FormEvent) => { 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 = ({ selectedPermissionsLink }) => { message = ( ); - } else if (error) { + } else if (permissionsUpdateError) { message = ; } @@ -107,12 +116,12 @@ const SetPermissions: FC = ({ selectedPermissionsLink }) => {
{message} - + @@ -121,4 +130,5 @@ const SetPermissions: FC = ({ selectedPermissionsLink }) => { ); }; + export default SetPermissions; diff --git a/scm-ui/ui-webapp/src/permissions/components/handlePermissions.ts b/scm-ui/ui-webapp/src/permissions/components/SetUserPermissions.tsx similarity index 52% rename from scm-ui/ui-webapp/src/permissions/components/handlePermissions.ts rename to scm-ui/ui-webapp/src/permissions/components/SetUserPermissions.tsx index 7c2b85cdcc..d8a75fcc9c 100644 --- a/scm-ui/ui-webapp/src/permissions/components/handlePermissions.ts +++ b/scm-ui/ui-webapp/src/permissions/components/SetUserPermissions.tsx @@ -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 = ({ user }) => { + const { + data: selectedPermissions, + isLoading: loadingPermissions, + error: permissionsLoadError, + } = useUserPermissions(user); + const { + isLoading: isUpdatingPermissions, + isUpdated: permissionsUpdated, + setPermissions, + error: permissionsUpdateError, + } = useSetUserPermissions(user, selectedPermissions); + return ( + + ); +}; -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; diff --git a/scm-ui/ui-webapp/src/permissions/components/handlePermissions.test.ts b/scm-ui/ui-webapp/src/permissions/components/handlePermissions.test.ts deleted file mode 100644 index d5c91a8d83..0000000000 --- a/scm-ui/ui-webapp/src/permissions/components/handlePermissions.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx index 820c09740b..b9128253aa 100644 --- a/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/containers/DeleteBranch.tsx @@ -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 = ({ repository, branch }: Props) => { const [showConfirmAlert, setShowConfirmAlert] = useState(false); - const [error, setError] = useState(); 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 ; + } if (!branch._links.delete) { return null; @@ -59,12 +56,13 @@ const DeleteBranch: FC = ({ 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)} /> diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx index 81175f7d2d..cf6fd0133a 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx @@ -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 = ({ setCompressed, password, setPassword, - disabled + disabled, }) => { const [t] = useTranslation("repos"); @@ -54,7 +53,7 @@ const ImportFromBundleForm: FC = ({
{ + handleFile={(file) => { setFile(file); setValid(!!file); }} @@ -75,7 +74,7 @@ const ImportFromBundleForm: FC = ({
setPassword(value)} + onChange={(value) => setPassword(value)} type="password" label={t("import.bundle.password.title")} helpText={t("import.bundle.password.helpText")} diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx index 6a6123b6b1..ab8cd57036 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx @@ -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; - informationForm: React.ComponentType; + setImportedRepository: (repository: Repository) => void; + nameForm: extensionPoints.RepositoryCreatorComponentProps["nameForm"]; + informationForm: extensionPoints.RepositoryCreatorComponentProps["informationForm"]; }; const ImportFullRepository: FC = ({ - url, repositoryType, setImportPending, + setImportedRepository, nameForm: NameForm, - informationForm: InformationForm + informationForm: InformationForm, }) => { const [repo, setRepo] = useState({ 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(); const [file, setFile] = useState(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) => { 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 = ({ repository={repo} onChange={setRepo as React.Dispatch>} setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} - disabled={loading} + disabled={isLoading} /> >} - disabled={loading} + disabled={isLoading} setValid={(contact: boolean) => setValid({ ...valid, contact })} /> } + right={} /> ); diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFullRepositoryForm.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFullRepositoryForm.tsx index 4776f1d457..e85bf3a4d2 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportFullRepositoryForm.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportFullRepositoryForm.tsx @@ -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 = ({ setFile, setValid, password, setP
setPassword(value)} + onChange={(value) => setPassword(value)} type="password" label={t("import.bundle.password.title")} helpText={t("import.bundle.password.helpText")} diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx index 341eac8827..e420d68353 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx @@ -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; - informationForm: React.ComponentType; + setImportedRepository: (repository: Repository) => void; + nameForm: extensionPoints.RepositoryCreatorComponentProps["nameForm"]; + informationForm: extensionPoints.RepositoryCreatorComponentProps["informationForm"]; }; const ImportRepositoryFromBundle: FC = ({ - url, repositoryType, setImportPending, + setImportedRepository, nameForm: NameForm, - informationForm: InformationForm + informationForm: InformationForm, }) => { const [repo, setRepo] = useState({ 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(); const [file, setFile] = useState(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) => { 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 = ({ setCompressed={setCompressed} password={password} setPassword={setPassword} - disabled={loading} + disabled={isLoading} />
>} setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} - disabled={loading} + disabled={isLoading} /> >} - disabled={loading} + disabled={isLoading} setValid={(contact: boolean) => setValid({ ...valid, contact })} /> } + right={} /> ); diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx index 3b9a485701..8220c66a7c 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromUrl.tsx @@ -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; - informationForm: React.ComponentType; + setImportedRepository: (repository: Repository) => void; + nameForm: extensionPoints.RepositoryCreatorComponentProps["nameForm"]; + informationForm: extensionPoints.RepositoryCreatorComponentProps["informationForm"]; }; const ImportRepositoryFromUrl: FC = ({ - url, repositoryType, setImportPending, + setImportedRepository, nameForm: NameForm, - informationForm: InformationForm + informationForm: InformationForm, }) => { const [repo, setRepo] = useState({ 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(); - 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) => { 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 (
- + {error ? : null} setValid({ ...valid, importUrl })} - disabled={loading} + disabled={isLoading} />
>} setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} - disabled={loading} + disabled={isLoading} /> >} - disabled={loading} + disabled={isLoading} setValid={(contact: boolean) => setValid({ ...valid, contact })} /> } + right={} /> ); diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index 9b0cd001ed..3b29788ad7 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -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 = ({ 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 = ({ repository }) => { const url = urls.matchedUrlFromMatch(match); const extensionProps = { repository, - url + url, }; return ( @@ -66,7 +65,7 @@ const EditRepo: FC = ({ repository }) => { {(repository._links.runHealthCheck || repository.healthCheckRunning) && ( )} - + ); }; diff --git a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx index 844311d86d..3e01b0226f 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx @@ -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 = ({ repositoryTypes, nameForm, informationForm }) => { +const ImportRepository: extensionPoints.RepositoryCreatorExtension["component"] = ({ + repositoryTypes, + nameForm, + informationForm, +}) => { const [importPending, setImportPending] = useState(false); + const [importedRepository, setImportedRepository] = useState(); const [repositoryType, setRepositoryType] = useState(); const [importType, setImportType] = useState(""); const [t] = useTranslation("repos"); @@ -67,9 +72,9 @@ const ImportRepository: FC = ({ repositoryTypes, nameForm if (importType === "url") { return ( 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 = ({ repositoryTypes, nameForm if (importType === "bundle") { return ( 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 = ({ repositoryTypes, nameForm if (importType === "fullImport") { return ( 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 = ({ repositoryTypes, nameForm throw new Error("Unknown import type"); }; + if (importedRepository) { + return ; + } + return ( <> diff --git a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx index ba7d00cf12..e1558a37de 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx @@ -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 = ({ repository, indexLinks }) => { - const history = useHistory(); +const RenameRepository: FC = ({ repository }) => { const [t] = useTranslation("repos"); - const [error, setError] = useState(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 ; + if (isRenamed) { + return ; } - if (loading) { + if (namespaceStrategyLoadError) { + return ; + } + + if (isLoadingNamespaceStrategies) { return ; } @@ -88,31 +83,19 @@ const RenameRepository: FC = ({ 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 ; } return ; }; - 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 = (
+ {renamingError ? : null} = ({ repository, indexLinks }) => { label={t("renameRepo.modal.button.rename")} disabled={!isValid} title={t("renameRepo.modal.button.rename")} - action={rename} + loading={isRenaming} + action={() => renameRepository(namespace, name)} />
diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx index b49c9f4f5c..82d12e2109 100644 --- a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx @@ -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; }; export const ApiKeyEntry: FC = ({ apiKey, onDelete }) => { @@ -38,7 +38,7 @@ export const ApiKeyEntry: FC = ({ apiKey, onDelete }) => { let deleteButton; if (apiKey?._links?.delete) { deleteButton = ( - onDelete((apiKey._links.delete as Link).href)}> + onDelete(apiKey)}> diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx index da98781efd..19e2bf1509 100644 --- a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx @@ -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; }; const ApiKeyTable: FC = ({ apiKeys, onDelete }) => { diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx index 7b9af8a415..918c24fd26 100644 --- a/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx @@ -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 = ({ user }) => { const [t] = useTranslation("users"); - const [error, setError] = useState(); - const [loading, setLoading] = useState(false); - const [apiKeys, setApiKeys] = useState(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 = ({ user }) => { return ; } - if (loading) { + if (!apiKeys || isLoading) { return ; } @@ -91,12 +55,12 @@ const SetApiKeys: FC = ({ user }) => { <>

- {t("apiKey.text1")} {t("apiKey.manageRoles")} + {t("apiKey.text1")} {t("apiKey.manageRoles")}

{t("apiKey.text2")}


- - {createLink && } + + {createLink && } ); }; diff --git a/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx b/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx index c693d89130..6101b16995 100644 --- a/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx +++ b/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx @@ -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 = ({ createLink, refresh }) => { +const AddPublicKey: FC = ({ user, publicKeys }) => { const [t] = useTranslation("users"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); + const { isLoading, error, create } = useCreatePublicKey(user, publicKeys); const [displayName, setDisplayName] = useState(""); const [raw, setRaw] = useState(""); @@ -58,31 +49,26 @@ const AddPublicKey: FC = ({ 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 ; - } - - if (loading) { - return ; - } - return ( <>
+ {error ? : null}