Resource account page (#3982)

This commit is contained in:
Erik Jan de Wit 2023-01-16 11:23:07 +01:00 committed by GitHub
parent 8cd77d391b
commit c35e37ba4f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1536 additions and 14 deletions

View file

@ -1,8 +1,18 @@
{ {
"accept": "Accept",
"accountSecurity": "Account security", "accountSecurity": "Account security",
"add": "Add",
"application": "Application",
"applications": "Applications", "applications": "Applications",
"avatar": "Avatar", "avatar": "Avatar",
"cancel": "Cancel",
"close": "Close",
"deviceActivity": "Device activity", "deviceActivity": "Device activity",
"doDeny": "Deny",
"done": "Done",
"edit": "Edit",
"editTheResource": "Share the resource - {{0}}",
"filterByName": "Filter By Name ...",
"firstName": "First name", "firstName": "First name",
"fullName": "{{givenName}} {{familyName}}", "fullName": "{{givenName}} {{familyName}}",
"groups": "Groups", "groups": "Groups",
@ -10,15 +20,35 @@
"linkedAccounts": "Linked accounts", "linkedAccounts": "Linked accounts",
"logo": "Logo", "logo": "Logo",
"manageAccount": "Manage account", "manageAccount": "Manage account",
"myResources": "My Resources",
"permissionRequest": "Permission requests - {{0}}",
"permissionRequests": "Permission requests",
"permissions": "Permissions",
"personalInfo": "Personal info", "personalInfo": "Personal info",
"personalInfoDescription": "Manage your basic information", "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", "resources": "Resources",
"resourceSharedWith_one": "Resource is shared with <0>{{username}}</0>",
"resourceSharedWith_other": "Resource is shared with <0>{{username}}</0> and <1>{{other}}</1> 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", "signingIn": "Signing in",
"signOut": "Sign out", "signOut": "Sign out",
"somethingWentWrong": "Something went wrong", "somethingWentWrong": "Something went wrong",
"somethingWentWrongDescription": "Sorry, an unexpected error has occurred.", "somethingWentWrongDescription": "Sorry, an unexpected error has occurred.",
"tryAgain": "Try again", "tryAgain": "Try again",
"unknownUser": "Anonymous", "unknownUser": "Anonymous",
"unShare": "Unshare all",
"user": "User",
"username": "Username", "username": "Username",
"usernamePlaceholder": "Username or email",
"welcomeMessage": "Welcome to Keycloak Account Management." "welcomeMessage": "Welcome to Keycloak Account Management."
} }

View file

@ -1,12 +1,71 @@
import { Links, parseLinks } from "./api/parse-links";
import { Permission, Resource, Scope } from "./api/representations";
import { environment } from "./environment"; import { environment } from "./environment";
import { keycloak } from "./keycloak"; import { keycloak } from "./keycloak";
import { UserRepresentation } from "./representations";
import { joinPath } from "./utils/joinPath"; import { joinPath } from "./utils/joinPath";
export const fetchPersonalInfo = (params: RequestInit) => export const fetchResources = async (
get<UserRepresentation>("/", params); params: RequestInit,
requestParams: Record<string, string>,
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<T>(path: string, params: RequestInit): Promise<T> { 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<Permission[]> => {
const response = await request<Permission[]>(
`/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<T>(response: T) {
if (!response) throw new Error("Could not fetch");
return response;
}
async function get(path: string, params: RequestInit): Promise<Response> {
const url = joinPath( const url = joinPath(
environment.authServerUrl, environment.authServerUrl,
"realms", "realms",
@ -23,7 +82,18 @@ async function get<T>(path: string, params: RequestInit): Promise<T> {
}, },
}); });
return response.json(); if (!response.ok) {
throw new Error(response.statusText);
}
return response;
}
async function request<T>(
path: string,
params: RequestInit
): Promise<T | undefined> {
const response = await get(path, params);
if (response.status !== 204) return response.json();
} }
async function getAccessToken() { async function getAccessToken() {

View file

@ -0,0 +1,2 @@
export const CONTENT_TYPE_HEADER = "content-type";
export const CONTENT_TYPE_JSON = "application/json";

View file

@ -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<UserRepresentation> {
const response = await request("/", { signal });
return parseResponse<UserRepresentation>(response);
}
export async function getPermissionRequests(
resourceId: string,
{ signal }: CallOptions = {}
): Promise<Permission[]> {
const response = await request(
`/resources/${resourceId}/permissions/requests`,
{ signal }
);
return parseResponse<Permission[]>(response);
}

View file

@ -0,0 +1,28 @@
export type Links = {
prev?: Record<string, string>;
next?: Record<string, string>;
};
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*</);
return links.reduce<Links>((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<string, string> = {};
for (const [key, value] of new URL(linkUrl).searchParams.entries()) {
link[key] = value;
}
acc[rel[2] as keyof Links] = link;
}
return acc;
}, {});
}

View file

@ -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<T>(response: Response): Promise<T> {
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<unknown> {
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."
);
}

View file

@ -16,7 +16,7 @@ export interface ClientRepresentation {
rootUrl: string; rootUrl: string;
baseUrl: string; baseUrl: string;
effectiveUrl: string; effectiveUrl: string;
consent: ConsentRepresentation; consent?: ConsentRepresentation;
logoUri: string; logoUri: string;
policyUri: string; policyUri: string;
tosUri: string; tosUri: string;
@ -145,3 +145,60 @@ export interface CredentialRepresentation {
*/ */
config: { [index: string]: string[] }; 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;
}

View file

@ -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<string, string>;
body?: unknown;
};
export async function request(
path: string,
{ signal, method, searchParams, body }: RequestOptions = {}
): Promise<Response> {
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;
}

View file

@ -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<AlertProps | undefined>(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<AlertType[]>([]);
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 (
<AlertContext.Provider value={{ addAlert, addError }}>
<AlertGroup isToast>
{alerts.map(({ id, variant, message, description }) => (
<Alert
key={id}
isLiveRegion
variant={AlertVariant[variant]}
variantLabel=""
title={message}
actionClose={
<AlertActionCloseButton
title={message}
onClose={() => hideAlert(id)}
/>
}
timeout
onTimeout={() => hideAlert(id)}
>
{description && <p>{description}</p>}
</Alert>
))}
</AlertGroup>
{children}
</AlertContext.Provider>
);
};

View file

@ -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<ModalProps, "ref" | "children"> & {
modalTitle: TranslationKeys;
modalMessage?: string;
buttonTitle: TranslationKeys | ReactNode;
buttonVariant?: ButtonProps["variant"];
isDisabled?: boolean;
onContinue: () => void;
continueLabel?: TranslationKeys;
cancelLabel?: TranslationKeys;
component?: React.ElementType<any> | React.ComponentType<any>;
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 (
<>
<Component
variant={buttonVariant}
onClick={() => setOpen(true)}
isDisabled={isDisabled}
>
{
//@ts-ignore
typeof buttonTitle === "string" ? t(buttonTitle) : buttonTitle
}
</Component>
<Modal
variant="small"
{...rest}
//@ts-ignore
title={t(modalTitle)}
isOpen={open}
onClose={() => setOpen(false)}
actions={[
<Button
id="modal-confirm"
key="confirm"
variant="primary"
onClick={() => {
setOpen(false);
onContinue();
}}
>
{
//@ts-ignore
t(continueLabel)
}
</Button>,
<Button
id="modal-cancel"
key="cancel"
variant="secondary"
onClick={() => setOpen(false)}
>
{
//@ts-ignore
t(cancelLabel)
}
</Button>,
]}
>
{
//@ts-ignore
modalMessage ? t(modalMessage) : children
}
</Modal>
</>
);
};

View file

@ -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<T> = FieldPath<T>
> = Omit<
SelectProps,
"name" | "onToggle" | "selections" | "onSelect" | "onClear" | "isOpen"
> &
UseControllerProps<T, P> & {
name: string;
label?: string;
options: string[] | Option[];
controller: Omit<ControllerProps, "name" | "render">;
};
export const SelectControl = <
T extends FieldValues,
P extends FieldPath<T> = FieldPath<T>
>({
name,
label,
options,
controller,
...rest
}: SelectControlProps<T, P>) => {
const {
control,
formState: { errors },
} = useFormContext();
const [open, setOpen] = useState(false);
return (
<FormGroup
isRequired={controller.rules?.required === true}
label={label || name}
fieldId={name}
helperTextInvalid={errors[name]?.message}
validated={
errors[name] ? ValidatedOptions.error : ValidatedOptions.default
}
>
<Controller
{...controller}
name={name}
control={control}
render={({ field: { onChange, value } }) => (
<Select
{...rest}
toggleId={name}
onToggle={(isOpen) => setOpen(isOpen)}
selections={value}
onSelect={(_, v) => {
const option = v.toString();
if (value.includes(option)) {
onChange(value.filter((item: string) => item !== option));
} else {
onChange([...value, option]);
}
}}
onClear={(event) => {
event.stopPropagation();
onChange([]);
}}
isOpen={open}
validated={
errors[name] ? ValidatedOptions.error : ValidatedOptions.default
}
>
{options.map((option) => (
<SelectOption
key={typeof option === "string" ? option : option.key}
value={typeof option === "string" ? option : option.key}
>
{typeof option === "string" ? option : option.value}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
);
};

View file

@ -2,10 +2,10 @@ import { Form } from "@patternfly/react-core";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; 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 { TextControl } from "../components/controls/TextControl";
import { Page } from "../components/page/Page"; import { Page } from "../components/page/Page";
import { UserRepresentation } from "../representations";
import { usePromise } from "../utils/usePromise"; import { usePromise } from "../utils/usePromise";
const PersonalInfo = () => { const PersonalInfo = () => {
@ -14,7 +14,7 @@ const PersonalInfo = () => {
mode: "onChange", mode: "onChange",
}); });
usePromise((signal) => fetchPersonalInfo({ signal }), reset); usePromise((signal) => getPersonalInfo({ signal }), reset);
return ( return (
<Page title={t("personalInfo")} description={t("personalInfoDescription")}> <Page title={t("personalInfo")} description={t("personalInfoDescription")}>

View file

@ -3,6 +3,8 @@ import "i18next";
import translation from "../public/locales/en/translation.json"; import translation from "../public/locales/en/translation.json";
export type TranslationKeys = keyof translation;
declare module "i18next" { declare module "i18next" {
interface CustomTypeOptions { interface CustomTypeOptions {
defaultNS: "translation"; defaultNS: "translation";

View file

@ -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<FormValues>({ shouldUnregister: false });
const { control, register, reset, handleSubmit } = form;
const { fields } = useFieldArray<FormValues>({
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 (
<Modal
title={t("editTheResource", [resource.name])}
variant="medium"
isOpen
onClose={onClose}
actions={[
<Button
key="confirm"
variant="primary"
id="done"
type="submit"
form="edit-form"
>
{t("done")}
</Button>,
]}
>
<Form id="edit-form" onSubmit={handleSubmit(editShares)}>
<FormProvider {...form}>
{fields.map((p, index) => (
<Fragment key={p.id}>
<FormGroup label={t("user")} fieldId={`user-${p.id}`}>
<KeycloakTextInput
id={`user-${p.id}`}
type="text"
{...register(`permissions.${index}.username`)}
isDisabled
/>
</FormGroup>
<SelectControl
id={`permissions-${p.id}`}
name={`permissions.${index}.scopes`}
label="permissions"
variant="typeaheadmulti"
controller={{ defaultValue: [] }}
options={resource.scopes.map(({ name, displayName }) => ({
key: name,
value: displayName || name,
}))}
menuAppendTo="parent"
/>
</Fragment>
))}
</FormProvider>
</Form>
</Modal>
);
};

View file

@ -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 (
<>
<Button variant="link" onClick={toggle}>
<UserCheckIcon size="lg" />
<Badge>{resource.shareRequests.length}</Badge>
</Button>
<Modal
title={t("permissionRequest", [resource.name])}
variant={ModalVariant.large}
isOpen={open}
onClose={toggle}
actions={[
<Button key="close" variant="link" onClick={toggle}>
{t("close")}
</Button>,
]}
>
<TableComposable aria-label={t("resources")}>
<Thead>
<Tr>
<Th>{t("requestor")}</Th>
<Th>{t("permissionRequests")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{resource.shareRequests.map((shareRequest) => (
<Tr key={shareRequest.username}>
<Td>
{shareRequest.firstName} {shareRequest.lastName}{" "}
{shareRequest.lastName ? "" : shareRequest.username}
<br />
<Text component="small">{shareRequest.email}</Text>
</Td>
<Td>
{shareRequest.scopes.map((scope) => (
<Chip key={scope.toString()} isReadOnly>
{scope}
</Chip>
))}
</Td>
<Td>
<Button
onClick={() => {
approveDeny(shareRequest, true);
}}
>
{t("accept")}
</Button>
<Button
onClick={() => {
approveDeny(shareRequest);
}}
className="pf-u-ml-sm"
variant="danger"
>
{t("doDeny")}
</Button>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
</Modal>
</>
);
};

View file

@ -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 (
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<SearchInput
placeholder={t("filterByName")}
aria-label={t("filterByName")}
value={nameFilter}
onChange={setNameFilter}
onSearch={() => onFilter(nameFilter)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onFilter(nameFilter);
}
}}
onClear={() => {
setNameFilter("");
onFilter("");
}}
/>
</ToolbarItem>
<ToolbarItem variant="pagination">
<Pagination
isCompact
perPageOptions={[
{ title: "5", value: 5 },
{ title: "10", value: 10 },
{ title: "20", value: 20 },
]}
toggleTemplate={({
firstIndex,
lastIndex,
}: ToggleTemplateProps) => (
<b>
{firstIndex} - {lastIndex}
</b>
)}
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)}
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
);
};

View file

@ -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 = () => <PageSection>This is the resources page.</PageSection>; import { ResourcesTab } from "./ResourcesTab";
import { Page } from "../components/page/Page";
const Resources = () => {
const { t } = useTranslation();
const [activeTabKey, setActiveTabKey] = useState(0);
return (
<Page title={t("resources")} description={t("resourceIntroMessage")}>
<Tabs
activeKey={activeTabKey}
onSelect={(_, key) => setActiveTabKey(key as number)}
mountOnEnter
unmountOnExit
>
<Tab
eventKey={0}
title={<TabTitleText>{t("myResources")}</TabTitleText>}
>
<ResourcesTab />
</Tab>
<Tab
eventKey={1}
title={<TabTitleText>{t("sharedWithMe")}</TabTitleText>}
>
<h1>Share</h1>
</Tab>
</Tabs>
</Page>
);
};
export default Resources; export default Resources;

View file

@ -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<Record<string, string>>({
first: "0",
max: "5",
});
const [links, setLinks] = useState<Links | undefined>();
const [resources, setResources] = useState<Resource[]>();
const [details, setDetails] = useState<
Record<string, PermissionDetail | undefined>
>({});
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 <Spinner />;
}
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 (
<>
<ResourceToolbar
onFilter={(name) => 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}
/>
<TableComposable aria-label={t("resources")}>
<Thead>
<Tr>
<Th />
<Th>{t("resourceName")}</Th>
<Th>{t("application")}</Th>
<Th>{t("permissionRequests")}</Th>
</Tr>
</Thead>
{resources.map((resource, index) => (
<Tbody
key={resource.name}
isExpanded={details[resource._id]?.rowOpen}
>
<Tr>
<Td
expand={{
isExpanded: details[resource._id]?.rowOpen || false,
rowIndex: index,
onToggle: () =>
toggleOpen(
resource._id,
"rowOpen",
!details[resource._id]?.rowOpen
),
}}
/>
<Td dataLabel={t("resourceName")}>{resource.name}</Td>
<Td dataLabel={t("application")}>
<a href={resource.client.baseUrl}>
{resource.client.name || resource.client.clientId}{" "}
<ExternalLinkAltIcon />
</a>
</Td>
<Td dataLabel={t("permissionRequests")}>
{resource.shareRequests.length > 0 && (
<PermissionRequest
resource={resource}
refresh={() => refresh()}
/>
)}
<ShareTheResource
resource={resource}
permissions={details[resource._id]?.permissions}
open={details[resource._id]?.shareDialogOpen || false}
onClose={() => setDetails({})}
/>
{details[resource._id]?.editDialogOpen && (
<EditTheResource
resource={resource}
permissions={details[resource._id]?.permissions}
onClose={() => setDetails({})}
/>
)}
</Td>
<Td isActionCell>
<OverflowMenu breakpoint="lg">
<OverflowMenuContent>
<OverflowMenuGroup groupType="button">
<OverflowMenuItem>
<Button
variant="link"
onClick={() =>
toggleOpen(resource._id, "shareDialogOpen", true)
}
>
<ShareAltIcon /> {t("share")}
</Button>
</OverflowMenuItem>
<OverflowMenuItem>
<Dropdown
position="right"
toggle={
<KebabToggle
onToggle={(open) =>
toggleOpen(resource._id, "contextOpen", open)
}
/>
}
isOpen={details[resource._id]?.contextOpen}
isPlain
dropdownItems={[
<DropdownItem
key="edit"
isDisabled={
details[resource._id]?.permissions?.length === 0
}
onClick={() =>
toggleOpen(resource._id, "editDialogOpen", true)
}
>
<EditAltIcon /> {t("edit")}
</DropdownItem>,
<ContinueCancelModal
key="unShare"
isDisabled={
details[resource._id]?.permissions?.length === 0
}
buttonTitle={
<>
<Remove2Icon /> {t("unShare")}
</>
}
component={DropdownItem}
modalTitle="unShare"
modalMessage="unShareAllConfirm"
continueLabel="confirmButton"
onContinue={() => removeShare(resource)}
/>,
]}
/>
</OverflowMenuItem>
</OverflowMenuGroup>
</OverflowMenuContent>
<OverflowMenuControl>
<Dropdown
position="right"
toggle={
<KebabToggle
onToggle={(open) =>
toggleOpen(resource._id, "contextOpen", open)
}
/>
}
isOpen={details[resource._id]?.contextOpen}
isPlain
dropdownItems={[
<OverflowMenuDropdownItem
key="share"
isShared
onClick={() =>
toggleOpen(resource._id, "shareDialogOpen", true)
}
>
<ShareAltIcon /> {t("share")}
</OverflowMenuDropdownItem>,
<OverflowMenuDropdownItem
key="edit"
isShared
onClick={() =>
toggleOpen(resource._id, "editDialogOpen", true)
}
>
<EditAltIcon /> {t("edit")}
</OverflowMenuDropdownItem>,
<ContinueCancelModal
key="unShare"
isDisabled={
details[resource._id]?.permissions?.length === 0
}
buttonTitle={
<>
<Remove2Icon /> {t("unShare")}
</>
}
component={OverflowMenuDropdownItem}
modalTitle="unShare"
modalMessage="unShareAllConfirm"
continueLabel="confirmButton"
onContinue={() => removeShare(resource)}
/>,
]}
/>
</OverflowMenuControl>
</OverflowMenu>
</Td>
</Tr>
<Tr isExpanded={details[resource._id]?.rowOpen || false}>
<Td colSpan={4} textCenter>
<ExpandableRowContent>
<SharedWith
permissions={details[resource._id]?.permissions}
/>
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
))}
</TableComposable>
</>
);
};

View file

@ -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<FormValues>();
const {
control,
register,
reset,
formState: { errors, isValid },
setError,
clearErrors,
handleSubmit,
} = form;
const { fields, append, remove } = useFieldArray<FormValues>({
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 (
<Modal
title={t("shareTheResource", [resource.name])}
variant="medium"
isOpen={open}
onClose={onClose}
actions={[
<Button
key="confirm"
variant="primary"
id="done"
isDisabled={!isValid}
type="submit"
form="share-form"
>
{t("done")}
</Button>,
<Button key="cancel" variant="link" onClick={onClose}>
{t("cancel")}
</Button>,
]}
>
<Form id="share-form" onSubmit={handleSubmit(addShare)}>
<FormGroup
label={t("shareUser")}
type="string"
helperTextInvalid={errors.usernames?.message}
fieldId="users"
isRequired
validated={
errors.usernames ? ValidatedOptions.error : ValidatedOptions.default
}
>
<InputGroup>
<KeycloakTextInput
id="users"
placeholder={t("usernamePlaceholder")}
validated={
errors.usernames
? ValidatedOptions.error
: ValidatedOptions.default
}
{...register(`usernames.${fields.length - 1}.value`, {
validate: validateUser,
})}
/>
<Button
key="add-user"
variant="primary"
id="add"
onClick={() => append({ value: "" })}
isDisabled={isDisabled}
>
{t("add")}
</Button>
</InputGroup>
{fields.length > 1 && (
<ChipGroup categoryName={t("shareWith")}>
{fields.map(
(field, index) =>
index !== fields.length - 1 && (
<Chip key={field.id} onClick={() => remove(index)}>
{field.value}
</Chip>
)
)}
</ChipGroup>
)}
</FormGroup>
<FormProvider {...form}>
<FormGroup label="" fieldId="permissions-selected">
<SelectControl
name="permissions"
variant="typeaheadmulti"
controller={{ defaultValue: [] }}
options={resource.scopes.map(({ name, displayName }) => ({
key: name,
value: displayName || name,
}))}
menuAppendTo="parent"
/>
</FormGroup>
</FormProvider>
<FormGroup>
<SharedWith permissions={permissions} />
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -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 (
<Trans i18nKey="resourceSharedWith" count={p.length}>
<strong>
{{
username: p[0] ? p[0].username : undefined,
}}
</strong>
<strong>
{{
other: p.length - 1,
}}
</strong>
</Trans>
);
};

View file

@ -7,6 +7,7 @@ import {
import { Suspense, useMemo } from "react"; import { Suspense, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { AlertProvider } from "../components/alerts/Alerts";
import { environment } from "../environment"; import { environment } from "../environment";
import { keycloak } from "../keycloak"; import { keycloak } from "../keycloak";
@ -48,9 +49,11 @@ export const Root = () => {
sidebar={<PageNav />} sidebar={<PageNav />}
isManagedSidebar isManagedSidebar
> >
<Suspense fallback={<Spinner />}> <AlertProvider>
<Outlet /> <Suspense fallback={<Spinner />}>
</Suspense> <Outlet />
</Suspense>
</AlertProvider>
</Page> </Page>
); );
}; };

View file

@ -0,0 +1,2 @@
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;