diff --git a/apps/account-ui/public/locales/en/translation.json b/apps/account-ui/public/locales/en/translation.json index 9a15b24770..dcb8995f14 100644 --- a/apps/account-ui/public/locales/en/translation.json +++ b/apps/account-ui/public/locales/en/translation.json @@ -1,8 +1,18 @@ { + "accept": "Accept", "accountSecurity": "Account security", + "add": "Add", + "application": "Application", "applications": "Applications", "avatar": "Avatar", + "cancel": "Cancel", + "close": "Close", "deviceActivity": "Device activity", + "doDeny": "Deny", + "done": "Done", + "edit": "Edit", + "editTheResource": "Share the resource - {{0}}", + "filterByName": "Filter By Name ...", "firstName": "First name", "fullName": "{{givenName}} {{familyName}}", "groups": "Groups", @@ -10,15 +20,35 @@ "linkedAccounts": "Linked accounts", "logo": "Logo", "manageAccount": "Manage account", + "myResources": "My Resources", + "permissionRequest": "Permission requests - {{0}}", + "permissionRequests": "Permission requests", + "permissions": "Permissions", "personalInfo": "Personal info", "personalInfoDescription": "Manage your basic information", + "requestor": "Requestor", + "required": "Required", + "resourceAlreadyShared": "Resource is already shared with this user.", + "resourceIntroMessage": "Share your resources among team members", + "resourceName": "Resource name", "resources": "Resources", + "resourceSharedWith_one": "Resource is shared with <0>{{username}}", + "resourceSharedWith_other": "Resource is shared with <0>{{username}} and <1>{{other}} other users", + "resourceSharedWith_zero": "This resource is not shared.", + "share": "Share", + "sharedWithMe": "Shared with Me", + "shareTheResource": "Share the resource - {{0}}", + "shareUser": "Add users to share your resource with", + "shareWith": "Share with ", "signingIn": "Signing in", "signOut": "Sign out", "somethingWentWrong": "Something went wrong", "somethingWentWrongDescription": "Sorry, an unexpected error has occurred.", "tryAgain": "Try again", "unknownUser": "Anonymous", + "unShare": "Unshare all", + "user": "User", "username": "Username", + "usernamePlaceholder": "Username or email", "welcomeMessage": "Welcome to Keycloak Account Management." } diff --git a/apps/account-ui/src/api.ts b/apps/account-ui/src/api.ts index bc8cc9db70..c8330cd60c 100644 --- a/apps/account-ui/src/api.ts +++ b/apps/account-ui/src/api.ts @@ -1,12 +1,71 @@ +import { Links, parseLinks } from "./api/parse-links"; +import { Permission, Resource, Scope } from "./api/representations"; import { environment } from "./environment"; import { keycloak } from "./keycloak"; -import { UserRepresentation } from "./representations"; import { joinPath } from "./utils/joinPath"; -export const fetchPersonalInfo = (params: RequestInit) => - get("/", params); +export const fetchResources = async ( + params: RequestInit, + requestParams: Record, + shared: boolean | undefined = false +): Promise<{ data: Resource[]; links: Links }> => { + const response = await get( + `/resources${shared ? "/shared-with-me?" : "?"}${new URLSearchParams( + requestParams + )}`, + params + ); -async function get(path: string, params: RequestInit): Promise { + let links: Links; + + try { + links = parseLinks(response); + } catch (error) { + links = {}; + } + + return { + data: checkResponse(await response.json()), + links, + }; +}; + +export const fetchPermission = async ( + params: RequestInit, + resourceId: string +): Promise => { + const response = await request( + `/resources/${resourceId}/permissions`, + params + ); + return checkResponse(response); +}; + +export const updateRequest = ( + resourceId: string, + username: string, + scopes: Scope[] | string[] +) => + request(`/resources/${resourceId}/permissions`, { + method: "put", + body: JSON.stringify([{ username, scopes }]), + }); + +export const updatePermissions = ( + resourceId: string, + permissions: Permission[] +) => + request(`/resources/${resourceId}/permissions`, { + method: "put", + body: JSON.stringify(permissions), + }); + +function checkResponse(response: T) { + if (!response) throw new Error("Could not fetch"); + return response; +} + +async function get(path: string, params: RequestInit): Promise { const url = joinPath( environment.authServerUrl, "realms", @@ -23,7 +82,18 @@ async function get(path: string, params: RequestInit): Promise { }, }); - return response.json(); + if (!response.ok) { + throw new Error(response.statusText); + } + return response; +} + +async function request( + path: string, + params: RequestInit +): Promise { + const response = await get(path, params); + if (response.status !== 204) return response.json(); } async function getAccessToken() { diff --git a/apps/account-ui/src/api/constants.ts b/apps/account-ui/src/api/constants.ts new file mode 100644 index 0000000000..b38158e724 --- /dev/null +++ b/apps/account-ui/src/api/constants.ts @@ -0,0 +1,2 @@ +export const CONTENT_TYPE_HEADER = "content-type"; +export const CONTENT_TYPE_JSON = "application/json"; diff --git a/apps/account-ui/src/api/methods.ts b/apps/account-ui/src/api/methods.ts new file mode 100644 index 0000000000..e3f3eb928d --- /dev/null +++ b/apps/account-ui/src/api/methods.ts @@ -0,0 +1,31 @@ +import { parseResponse } from "./parse-response"; +import { Permission, UserRepresentation } from "./representations"; +import { request } from "./request"; + +export type CallOptions = { + signal?: AbortSignal; +}; + +export type PaginationParams = { + first: number; + max: number; +}; + +export async function getPersonalInfo({ + signal, +}: CallOptions = {}): Promise { + const response = await request("/", { signal }); + return parseResponse(response); +} + +export async function getPermissionRequests( + resourceId: string, + { signal }: CallOptions = {} +): Promise { + const response = await request( + `/resources/${resourceId}/permissions/requests`, + { signal } + ); + + return parseResponse(response); +} diff --git a/apps/account-ui/src/api/parse-links.ts b/apps/account-ui/src/api/parse-links.ts new file mode 100644 index 0000000000..699f779df2 --- /dev/null +++ b/apps/account-ui/src/api/parse-links.ts @@ -0,0 +1,28 @@ +export type Links = { + prev?: Record; + next?: Record; +}; + +export function parseLinks(response: Response): Links { + const linkHeader = response.headers.get("link"); + + if (!linkHeader) { + throw new Error("Attempted to parse links, but no header was found."); + } + + const links = linkHeader.split(/,\s*((acc: Links, link: string) => { + const matcher = link.match(/]*)>(.*)/); + if (!matcher) return {}; + const linkUrl = matcher[1]; + const rel = matcher[2].match(/\s*(.+)\s*=\s*"?([^"]+)"?/); + if (rel) { + const link: Record = {}; + for (const [key, value] of new URL(linkUrl).searchParams.entries()) { + link[key] = value; + } + acc[rel[2] as keyof Links] = link; + } + return acc; + }, {}); +} diff --git a/apps/account-ui/src/api/parse-response.ts b/apps/account-ui/src/api/parse-response.ts new file mode 100644 index 0000000000..6af206eb3e --- /dev/null +++ b/apps/account-ui/src/api/parse-response.ts @@ -0,0 +1,53 @@ +import { isRecord } from "../utils/isRecord"; +import { CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON } from "./constants"; + +export class ApiError extends Error {} + +export async function parseResponse(response: Response): Promise { + const contentType = response.headers.get(CONTENT_TYPE_HEADER); + const isJSON = contentType ? contentType.includes(CONTENT_TYPE_JSON) : false; + + if (!isJSON) { + throw new Error( + `Expected response to have a JSON content type, got '${contentType}' instead.` + ); + } + + const data = await parseJSON(response); + + if (!response.ok) { + throw new ApiError(getErrorMessage(data)); + } + + return data as T; +} + +async function parseJSON(response: Response): Promise { + try { + return await response.json(); + } catch (error) { + throw new Error("Unable to parse response as valid JSON.", { + cause: error, + }); + } +} + +function getErrorMessage(data: unknown): string { + if (!isRecord(data)) { + throw new Error("Unable to retrieve error message from response."); + } + + const errorKeys = ["error_description", "errorMessage", "error"]; + + for (const key of errorKeys) { + const value = data[key]; + + if (typeof value === "string") { + return value; + } + } + + throw new Error( + "Unable to retrieve error message from response, no matching key found." + ); +} diff --git a/apps/account-ui/src/representations.ts b/apps/account-ui/src/api/representations.ts similarity index 70% rename from apps/account-ui/src/representations.ts rename to apps/account-ui/src/api/representations.ts index 381f629d7e..8a79605594 100644 --- a/apps/account-ui/src/representations.ts +++ b/apps/account-ui/src/api/representations.ts @@ -16,7 +16,7 @@ export interface ClientRepresentation { rootUrl: string; baseUrl: string; effectiveUrl: string; - consent: ConsentRepresentation; + consent?: ConsentRepresentation; logoUri: string; policyUri: string; tosUri: string; @@ -145,3 +145,60 @@ export interface CredentialRepresentation { */ config: { [index: string]: string[] }; } + +export interface CredentialTypeMetadata { + type: string; + displayName: string; + helpText: string; + iconCssClass: string; + createAction: string; + updateAction: string; + removeable: boolean; + category: "basic-authentication" | "two-factor" | "passwordless"; +} + +export interface CredentialContainer { + type: string; + category: string; + displayName: string; + helptext: string; + iconCssClass: string; + createAction: string; + updateAction: string; + removeable: boolean; + userCredentialMetadatas: CredentialMetadataRepresentation[]; + metadata: CredentialTypeMetadata; +} + +export interface Client { + baseUrl: string; + clientId: string; + name?: string; +} + +export interface Scope { + name: string; + displayName?: string; +} + +export interface Resource { + _id: string; + name: string; + client: Client; + scopes: Scope[]; + uris: string[]; + shareRequests: Permission[]; +} + +export interface Permission { + email?: string; + firstName?: string; + lastName?: string; + scopes: Scope[] | string[]; // this should be Scope[] - fix API + username: string; +} + +export interface Permissions { + permissions: Permission[]; + row?: number; +} diff --git a/apps/account-ui/src/api/request.ts b/apps/account-ui/src/api/request.ts new file mode 100644 index 0000000000..031404f6bd --- /dev/null +++ b/apps/account-ui/src/api/request.ts @@ -0,0 +1,52 @@ +import { environment } from "../environment"; +import { keycloak } from "../keycloak"; +import { joinPath } from "../utils/joinPath"; +import { CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON } from "./constants"; + +export type RequestOptions = { + signal?: AbortSignal; + method?: "POST" | "PUT" | "DELETE"; + searchParams?: Record; + body?: unknown; +}; + +export async function request( + path: string, + { signal, method, searchParams, body }: RequestOptions = {} +): Promise { + const url = new URL( + joinPath( + environment.authServerUrl, + "realms", + environment.loginRealm, + "account", + path + ) + ); + + if (searchParams) { + Object.entries(searchParams).forEach(([key, value]) => + url.searchParams.set(key, value) + ); + } + + return fetch(url, { + signal, + method, + body: body ? JSON.stringify(body) : undefined, + headers: { + [CONTENT_TYPE_HEADER]: CONTENT_TYPE_JSON, + authorization: `Bearer ${await getAccessToken()}`, + }, + }); +} + +async function getAccessToken() { + try { + await keycloak.updateToken(5); + } catch (error) { + await keycloak.login(); + } + + return keycloak.token; +} diff --git a/apps/account-ui/src/components/alerts/Alerts.tsx b/apps/account-ui/src/components/alerts/Alerts.tsx new file mode 100644 index 0000000000..52e988fbad --- /dev/null +++ b/apps/account-ui/src/components/alerts/Alerts.tsx @@ -0,0 +1,96 @@ +import { createContext, FunctionComponent, useContext, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + AlertActionCloseButton, + AlertGroup, + AlertVariant, +} from "@patternfly/react-core"; +import { TranslationKeys } from "../../react-i18next"; + +export type AddAlertFunction = ( + message: TranslationKeys, + variant?: AlertVariant, + description?: string +) => void; + +export type AddErrorFunction = (message: TranslationKeys, error: any) => void; + +type AlertProps = { + addAlert: AddAlertFunction; + addError: AddErrorFunction; +}; + +export const AlertContext = createContext(undefined); + +export const useAlerts = () => useContext(AlertContext)!; + +export type AlertType = { + id: number; + message: string; + variant: AlertVariant; + description?: string; +}; + +export const AlertProvider: FunctionComponent = ({ children }) => { + const { t } = useTranslation(); + const [alerts, setAlerts] = useState([]); + + const hideAlert = (id: number) => { + setAlerts((alerts) => alerts.filter((alert) => alert.id !== id)); + }; + + const addAlert = ( + message: TranslationKeys, + variant: AlertVariant = AlertVariant.success, + description?: string + ) => { + setAlerts([ + { + id: Math.random() * 100, + //@ts-ignore + message: t(message), + variant, + description, + }, + ...alerts, + ]); + }; + + const addError = (message: TranslationKeys, error: Error | string) => { + addAlert( + //@ts-ignore + t(message, { + error, + }), + AlertVariant.danger + ); + }; + + return ( + + + {alerts.map(({ id, variant, message, description }) => ( + hideAlert(id)} + /> + } + timeout + onTimeout={() => hideAlert(id)} + > + {description &&

{description}

} +
+ ))} +
+ {children} +
+ ); +}; diff --git a/apps/account-ui/src/components/continue-cancel/ContinueCancelModel.tsx b/apps/account-ui/src/components/continue-cancel/ContinueCancelModel.tsx new file mode 100644 index 0000000000..0245e39115 --- /dev/null +++ b/apps/account-ui/src/components/continue-cancel/ContinueCancelModel.tsx @@ -0,0 +1,90 @@ +import { ReactNode, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, ButtonProps, Modal, ModalProps } from "@patternfly/react-core"; +import { TranslationKeys } from "../../react-i18next"; + +type ContinueCancelModalProps = Omit & { + modalTitle: TranslationKeys; + modalMessage?: string; + buttonTitle: TranslationKeys | ReactNode; + buttonVariant?: ButtonProps["variant"]; + isDisabled?: boolean; + onContinue: () => void; + continueLabel?: TranslationKeys; + cancelLabel?: TranslationKeys; + component?: React.ElementType | React.ComponentType; + children?: ReactNode; +}; + +export const ContinueCancelModal = ({ + modalTitle, + modalMessage, + buttonTitle, + isDisabled, + buttonVariant, + onContinue, + continueLabel = "continue", + cancelLabel = "doCancel", + component = "button", + children, + ...rest +}: ContinueCancelModalProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const Component = component; + + return ( + <> + setOpen(true)} + isDisabled={isDisabled} + > + { + //@ts-ignore + typeof buttonTitle === "string" ? t(buttonTitle) : buttonTitle + } + + setOpen(false)} + actions={[ + , + , + ]} + > + { + //@ts-ignore + modalMessage ? t(modalMessage) : children + } + + + ); +}; diff --git a/apps/account-ui/src/components/controls/SelectControl.tsx b/apps/account-ui/src/components/controls/SelectControl.tsx new file mode 100644 index 0000000000..d7a46e14bc --- /dev/null +++ b/apps/account-ui/src/components/controls/SelectControl.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { + Controller, + ControllerProps, + FieldValues, + FieldPath, + useFormContext, + UseControllerProps, +} from "react-hook-form"; +import { + FormGroup, + Select, + SelectOption, + SelectProps, + ValidatedOptions, +} from "@patternfly/react-core"; + +type Option = { + key: string; + value: string; +}; + +type SelectControlProps< + T extends FieldValues, + P extends FieldPath = FieldPath +> = Omit< + SelectProps, + "name" | "onToggle" | "selections" | "onSelect" | "onClear" | "isOpen" +> & + UseControllerProps & { + name: string; + label?: string; + options: string[] | Option[]; + controller: Omit; + }; + +export const SelectControl = < + T extends FieldValues, + P extends FieldPath = FieldPath +>({ + name, + label, + options, + controller, + ...rest +}: SelectControlProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + const [open, setOpen] = useState(false); + return ( + + ( + + )} + /> + + ); +}; diff --git a/apps/account-ui/src/personal-info/PersonalInfo.tsx b/apps/account-ui/src/personal-info/PersonalInfo.tsx index 641efbd842..c3e20c2616 100644 --- a/apps/account-ui/src/personal-info/PersonalInfo.tsx +++ b/apps/account-ui/src/personal-info/PersonalInfo.tsx @@ -2,10 +2,10 @@ import { Form } from "@patternfly/react-core"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { fetchPersonalInfo } from "../api"; +import { getPersonalInfo } from "../api/methods"; +import { UserRepresentation } from "../api/representations"; import { TextControl } from "../components/controls/TextControl"; import { Page } from "../components/page/Page"; -import { UserRepresentation } from "../representations"; import { usePromise } from "../utils/usePromise"; const PersonalInfo = () => { @@ -14,7 +14,7 @@ const PersonalInfo = () => { mode: "onChange", }); - usePromise((signal) => fetchPersonalInfo({ signal }), reset); + usePromise((signal) => getPersonalInfo({ signal }), reset); return ( diff --git a/apps/account-ui/src/react-i18next.d.ts b/apps/account-ui/src/react-i18next.d.ts index fbda76761d..f8277f2074 100644 --- a/apps/account-ui/src/react-i18next.d.ts +++ b/apps/account-ui/src/react-i18next.d.ts @@ -3,6 +3,8 @@ import "i18next"; import translation from "../public/locales/en/translation.json"; +export type TranslationKeys = keyof translation; + declare module "i18next" { interface CustomTypeOptions { defaultNS: "translation"; diff --git a/apps/account-ui/src/resources/EditTheResource.tsx b/apps/account-ui/src/resources/EditTheResource.tsx new file mode 100644 index 0000000000..dd7d3e2b06 --- /dev/null +++ b/apps/account-ui/src/resources/EditTheResource.tsx @@ -0,0 +1,102 @@ +import { Button, Form, FormGroup, Modal } from "@patternfly/react-core"; +import { Fragment, useEffect } from "react"; +import { FormProvider, useFieldArray, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { updatePermissions } from "../api"; +import type { Permission, Resource } from "../api/representations"; +import { useAlerts } from "../components/alerts/Alerts"; +import { SelectControl } from "../components/controls/SelectControl"; +import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput"; + +type EditTheResourceProps = { + resource: Resource; + permissions?: Permission[]; + onClose: () => void; +}; + +type FormValues = { + permissions: Permission[]; +}; + +export const EditTheResource = ({ + resource, + permissions, + onClose, +}: EditTheResourceProps) => { + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const form = useForm({ shouldUnregister: false }); + const { control, register, reset, handleSubmit } = form; + + const { fields } = useFieldArray({ + control, + name: "permissions", + }); + + useEffect(() => reset({ permissions }), []); + + const editShares = async ({ permissions }: FormValues) => { + try { + await Promise.all( + permissions.map((permission) => + updatePermissions(resource._id, [permission]) + ) + ); + addAlert("updateSuccess"); + onClose(); + } catch (error) { + addError("updateError", error); + } + }; + + return ( + + {t("done")} + , + ]} + > +
+ + {fields.map((p, index) => ( + + + + + ({ + key: name, + value: displayName || name, + }))} + menuAppendTo="parent" + /> + + ))} + +
+
+ ); +}; diff --git a/apps/account-ui/src/resources/PermissionRequest.tsx b/apps/account-ui/src/resources/PermissionRequest.tsx new file mode 100644 index 0000000000..77e534d160 --- /dev/null +++ b/apps/account-ui/src/resources/PermissionRequest.tsx @@ -0,0 +1,132 @@ +import { + Badge, + Button, + Chip, + Modal, + ModalVariant, + Text, +} from "@patternfly/react-core"; +import { UserCheckIcon } from "@patternfly/react-icons"; + +import { + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { fetchPermission, updateRequest } from "../api"; +import { Permission, Resource } from "../api/representations"; +import { useAlerts } from "../components/alerts/Alerts"; + +type PermissionRequestProps = { + resource: Resource; + refresh: () => void; +}; + +export const PermissionRequest = ({ + resource, + refresh, +}: PermissionRequestProps) => { + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const [open, setOpen] = useState(false); + + const toggle = () => setOpen(!open); + + const approveDeny = async ( + shareRequest: Permission, + approve: boolean = false + ) => { + try { + const permissions = await fetchPermission({}, resource._id); + const { scopes, username } = permissions.find( + (p) => p.username === shareRequest.username + )!; + + await updateRequest( + resource._id, + username, + approve + ? [...(scopes as string[]), ...(shareRequest.scopes as string[])] + : scopes + ); + addAlert("shareSuccess"); + toggle(); + refresh(); + } catch (error) { + addError("shareError", error); + } + }; + + return ( + <> + + + {t("close")} + , + ]} + > + + + + {t("requestor")} + {t("permissionRequests")} + + + + + {resource.shareRequests.map((shareRequest) => ( + + + {shareRequest.firstName} {shareRequest.lastName}{" "} + {shareRequest.lastName ? "" : shareRequest.username} +
+ {shareRequest.email} + + + {shareRequest.scopes.map((scope) => ( + + {scope} + + ))} + + + + + + + ))} + +
+
+ + ); +}; diff --git a/apps/account-ui/src/resources/ResourceToolbar.tsx b/apps/account-ui/src/resources/ResourceToolbar.tsx new file mode 100644 index 0000000000..1d7795bc37 --- /dev/null +++ b/apps/account-ui/src/resources/ResourceToolbar.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Pagination, + SearchInput, + ToggleTemplateProps, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; + +type ResourceToolbarProps = { + onFilter: (nameFilter: string) => void; + count: number; + first: number; + max: number; + onNextClick: (page: number) => void; + onPreviousClick: (page: number) => void; + onPerPageSelect: (max: number, first: number) => void; + hasNext: boolean; +}; + +export const ResourceToolbar = ({ + count, + first, + max, + onNextClick, + onPreviousClick, + onPerPageSelect, + onFilter, + hasNext, +}: ResourceToolbarProps) => { + const { t } = useTranslation(); + const [nameFilter, setNameFilter] = useState(""); + + const page = Math.round(first / max) + 1; + return ( + + + + onFilter(nameFilter)} + onKeyDown={(e) => { + if (e.key === "Enter") { + onFilter(nameFilter); + } + }} + onClear={() => { + setNameFilter(""); + onFilter(""); + }} + /> + + + ( + + {firstIndex} - {lastIndex} + + )} + itemCount={count + (page - 1) * max + (hasNext ? 1 : 0)} + page={page} + perPage={max} + onNextClick={(_, p) => onNextClick((p - 1) * max)} + onPreviousClick={(_, p) => onPreviousClick((p - 1) * max)} + onPerPageSelect={(_, m, f) => onPerPageSelect(f - 1, m)} + /> + + + + ); +}; diff --git a/apps/account-ui/src/resources/Resources.tsx b/apps/account-ui/src/resources/Resources.tsx index 9bc00c5054..a7cba15cd7 100644 --- a/apps/account-ui/src/resources/Resources.tsx +++ b/apps/account-ui/src/resources/Resources.tsx @@ -1,5 +1,37 @@ -import { PageSection } from "@patternfly/react-core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Tab, Tabs, TabTitleText } from "@patternfly/react-core"; -const Resources = () => This is the resources page.; +import { ResourcesTab } from "./ResourcesTab"; +import { Page } from "../components/page/Page"; + +const Resources = () => { + const { t } = useTranslation(); + const [activeTabKey, setActiveTabKey] = useState(0); + + return ( + + setActiveTabKey(key as number)} + mountOnEnter + unmountOnExit + > + {t("myResources")}} + > + + + {t("sharedWithMe")}} + > +

Share

+
+
+
+ ); +}; export default Resources; diff --git a/apps/account-ui/src/resources/ResourcesTab.tsx b/apps/account-ui/src/resources/ResourcesTab.tsx new file mode 100644 index 0000000000..e57d315360 --- /dev/null +++ b/apps/account-ui/src/resources/ResourcesTab.tsx @@ -0,0 +1,324 @@ +import { + Button, + Dropdown, + DropdownItem, + KebabToggle, + OverflowMenu, + OverflowMenuContent, + OverflowMenuControl, + OverflowMenuDropdownItem, + OverflowMenuGroup, + OverflowMenuItem, + Spinner, +} from "@patternfly/react-core"; +import { + EditAltIcon, + ExternalLinkAltIcon, + Remove2Icon, + ShareAltIcon, +} from "@patternfly/react-icons"; +import { + ExpandableRowContent, + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { fetchPermission, fetchResources, updatePermissions } from "../api"; +import { getPermissionRequests } from "../api/methods"; +import { Links } from "../api/parse-links"; +import { Permission, Resource } from "../api/representations"; +import { useAlerts } from "../components/alerts/Alerts"; +import { ContinueCancelModal } from "../components/continue-cancel/ContinueCancelModel"; +import { usePromise } from "../utils/usePromise"; +import { EditTheResource } from "./EditTheResource"; +import { PermissionRequest } from "./PermissionRequest"; +import { ResourceToolbar } from "./ResourceToolbar"; +import { SharedWith } from "./SharedWith"; +import { ShareTheResource } from "./ShareTheResource"; + +type PermissionDetail = { + contextOpen?: boolean; + rowOpen?: boolean; + shareDialogOpen?: boolean; + editDialogOpen?: boolean; + permissions?: Permission[]; +}; + +export const ResourcesTab = () => { + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const [params, setParams] = useState>({ + first: "0", + max: "5", + }); + const [links, setLinks] = useState(); + const [resources, setResources] = useState(); + const [details, setDetails] = useState< + Record + >({}); + const [key, setKey] = useState(1); + const refresh = () => setKey(key + 1); + + usePromise( + async (signal) => { + const result = await fetchResources({ signal }, params); + await Promise.all( + result.data.map( + async (r) => + (r.shareRequests = await getPermissionRequests(r._id, { signal })) + ) + ); + return result; + }, + ({ data, links }) => { + setResources(data); + setLinks(links); + }, + [params, key] + ); + + if (!resources) { + return ; + } + + const fetchPermissions = async (id: string) => { + let permissions = details[id]?.permissions || []; + if (!details[id]) { + permissions = await fetchPermission({}, id); + } + return permissions; + }; + + const removeShare = async (resource: Resource) => { + try { + const permissions = (await fetchPermissions(resource._id)).map( + ({ username }) => + ({ + username, + scopes: [], + } as Permission) + )!; + await updatePermissions(resource._id, permissions); + setDetails({}); + addAlert("unShareSuccess"); + } catch (error) { + addError("updateError", error); + } + }; + + const toggleOpen = async ( + id: string, + field: keyof PermissionDetail, + open: boolean + ) => { + const permissions = await fetchPermissions(id); + + setDetails({ + ...details, + [id]: { ...details[id], [field]: open, permissions }, + }); + }; + + return ( + <> + setParams({ ...params, name })} + count={resources.length} + first={parseInt(params["first"])} + max={parseInt(params["max"])} + onNextClick={() => setParams(links?.next || {})} + onPreviousClick={() => setParams(links?.prev || {})} + onPerPageSelect={(first, max) => + setParams({ first: `${first}`, max: `${max}` }) + } + hasNext={!!links?.next} + /> + + + + + {t("resourceName")} + {t("application")} + {t("permissionRequests")} + + + {resources.map((resource, index) => ( + + + + toggleOpen( + resource._id, + "rowOpen", + !details[resource._id]?.rowOpen + ), + }} + /> + {resource.name} + + + {resource.client.name || resource.client.clientId}{" "} + + + + + {resource.shareRequests.length > 0 && ( + refresh()} + /> + )} + setDetails({})} + /> + {details[resource._id]?.editDialogOpen && ( + setDetails({})} + /> + )} + + + + + + + + + + + toggleOpen(resource._id, "contextOpen", open) + } + /> + } + isOpen={details[resource._id]?.contextOpen} + isPlain + dropdownItems={[ + + toggleOpen(resource._id, "editDialogOpen", true) + } + > + {t("edit")} + , + + {t("unShare")} + + } + component={DropdownItem} + modalTitle="unShare" + modalMessage="unShareAllConfirm" + continueLabel="confirmButton" + onContinue={() => removeShare(resource)} + />, + ]} + /> + + + + + + toggleOpen(resource._id, "contextOpen", open) + } + /> + } + isOpen={details[resource._id]?.contextOpen} + isPlain + dropdownItems={[ + + toggleOpen(resource._id, "shareDialogOpen", true) + } + > + {t("share")} + , + + toggleOpen(resource._id, "editDialogOpen", true) + } + > + {t("edit")} + , + + {t("unShare")} + + } + component={OverflowMenuDropdownItem} + modalTitle="unShare" + modalMessage="unShareAllConfirm" + continueLabel="confirmButton" + onContinue={() => removeShare(resource)} + />, + ]} + /> + + + + + + + + + + + + + ))} + + + ); +}; diff --git a/apps/account-ui/src/resources/ShareTheResource.tsx b/apps/account-ui/src/resources/ShareTheResource.tsx new file mode 100644 index 0000000000..6a44f43597 --- /dev/null +++ b/apps/account-ui/src/resources/ShareTheResource.tsx @@ -0,0 +1,205 @@ +import { + Button, + Chip, + ChipGroup, + Form, + FormGroup, + InputGroup, + Modal, + ValidatedOptions, +} from "@patternfly/react-core"; +import { useEffect } from "react"; +import { + FormProvider, + useFieldArray, + useForm, + useWatch, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { updateRequest } from "../api"; +import { Permission, Resource } from "../api/representations"; +import { useAlerts } from "../components/alerts/Alerts"; +import { SelectControl } from "../components/controls/SelectControl"; +import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput"; +import { SharedWith } from "./SharedWith"; + +type ShareTheResourceProps = { + resource: Resource; + permissions?: Permission[]; + open: boolean; + onClose: () => void; +}; + +type FormValues = { + permissions: string[]; + usernames: { value: string }[]; +}; + +export const ShareTheResource = ({ + resource, + permissions, + open, + onClose, +}: ShareTheResourceProps) => { + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + const form = useForm(); + const { + control, + register, + reset, + formState: { errors, isValid }, + setError, + clearErrors, + handleSubmit, + } = form; + const { fields, append, remove } = useFieldArray({ + control, + name: "usernames", + }); + + useEffect(() => { + if (fields.length === 0) { + append({ value: "" }); + } + }, [fields]); + + const watchFields = useWatch({ + control, + name: "usernames", + defaultValue: [], + }); + + const isDisabled = watchFields.every( + ({ value }) => value.trim().length === 0 + ); + + const addShare = async ({ usernames, permissions }: FormValues) => { + try { + await Promise.all( + usernames + .filter(({ value }) => value !== "") + .map(({ value: username }) => + updateRequest(resource._id, username, permissions) + ) + ); + addAlert("shareSuccess"); + onClose(); + } catch (error) { + addError("shareError", error); + } + reset({}); + }; + + const validateUser = async () => { + const userOrEmails = fields.map((f) => f.value).filter((f) => f !== ""); + const userPermission = permissions + ?.map((p) => [p.username, p.email]) + .flat(); + + const hasUsers = userOrEmails.length > 0; + const alreadyShared = + userOrEmails.filter((u) => userPermission?.includes(u)).length !== 0; + + if (!hasUsers || alreadyShared) { + setError("usernames", { + message: !hasUsers ? t("required") : t("resourceAlreadyShared"), + }); + } else { + clearErrors(); + } + + return hasUsers && !alreadyShared; + }; + + return ( + + {t("done")} + , + , + ]} + > +
+ + + + + + {fields.length > 1 && ( + + {fields.map( + (field, index) => + index !== fields.length - 1 && ( + remove(index)}> + {field.value} + + ) + )} + + )} + + + + ({ + key: name, + value: displayName || name, + }))} + menuAppendTo="parent" + /> + + + + + +
+
+ ); +}; diff --git a/apps/account-ui/src/resources/SharedWith.tsx b/apps/account-ui/src/resources/SharedWith.tsx new file mode 100644 index 0000000000..a61c597d10 --- /dev/null +++ b/apps/account-ui/src/resources/SharedWith.tsx @@ -0,0 +1,24 @@ +import { Trans } from "react-i18next"; + +import { Permission } from "../api/representations"; + +type SharedWithProps = { + permissions?: Permission[]; +}; + +export const SharedWith = ({ permissions: p = [] }: SharedWithProps) => { + return ( + + + {{ + username: p[0] ? p[0].username : undefined, + }} + + + {{ + other: p.length - 1, + }} + + + ); +}; diff --git a/apps/account-ui/src/root/Root.tsx b/apps/account-ui/src/root/Root.tsx index 491f1ee5d7..8d865783a7 100644 --- a/apps/account-ui/src/root/Root.tsx +++ b/apps/account-ui/src/root/Root.tsx @@ -7,6 +7,7 @@ import { import { Suspense, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Outlet } from "react-router"; +import { AlertProvider } from "../components/alerts/Alerts"; import { environment } from "../environment"; import { keycloak } from "../keycloak"; @@ -48,9 +49,11 @@ export const Root = () => { sidebar={} isManagedSidebar > - }> - - + + }> + + +
); }; diff --git a/apps/account-ui/src/utils/isRecord.ts b/apps/account-ui/src/utils/isRecord.ts new file mode 100644 index 0000000000..edc914cfb4 --- /dev/null +++ b/apps/account-ui/src/utils/isRecord.ts @@ -0,0 +1,2 @@ +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null;