Resource account page (#3982)
This commit is contained in:
parent
8cd77d391b
commit
c35e37ba4f
22 changed files with 1536 additions and 14 deletions
|
@ -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}}</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",
|
||||
"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."
|
||||
}
|
||||
|
|
|
@ -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<UserRepresentation>("/", params);
|
||||
export const fetchResources = async (
|
||||
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(
|
||||
environment.authServerUrl,
|
||||
"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() {
|
||||
|
|
2
apps/account-ui/src/api/constants.ts
Normal file
2
apps/account-ui/src/api/constants.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const CONTENT_TYPE_HEADER = "content-type";
|
||||
export const CONTENT_TYPE_JSON = "application/json";
|
31
apps/account-ui/src/api/methods.ts
Normal file
31
apps/account-ui/src/api/methods.ts
Normal 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);
|
||||
}
|
28
apps/account-ui/src/api/parse-links.ts
Normal file
28
apps/account-ui/src/api/parse-links.ts
Normal 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;
|
||||
}, {});
|
||||
}
|
53
apps/account-ui/src/api/parse-response.ts
Normal file
53
apps/account-ui/src/api/parse-response.ts
Normal 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."
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
52
apps/account-ui/src/api/request.ts
Normal file
52
apps/account-ui/src/api/request.ts
Normal 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;
|
||||
}
|
96
apps/account-ui/src/components/alerts/Alerts.tsx
Normal file
96
apps/account-ui/src/components/alerts/Alerts.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
102
apps/account-ui/src/components/controls/SelectControl.tsx
Normal file
102
apps/account-ui/src/components/controls/SelectControl.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
<Page title={t("personalInfo")} description={t("personalInfoDescription")}>
|
||||
|
|
2
apps/account-ui/src/react-i18next.d.ts
vendored
2
apps/account-ui/src/react-i18next.d.ts
vendored
|
@ -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";
|
||||
|
|
102
apps/account-ui/src/resources/EditTheResource.tsx
Normal file
102
apps/account-ui/src/resources/EditTheResource.tsx
Normal 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>
|
||||
);
|
||||
};
|
132
apps/account-ui/src/resources/PermissionRequest.tsx
Normal file
132
apps/account-ui/src/resources/PermissionRequest.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
85
apps/account-ui/src/resources/ResourceToolbar.tsx
Normal file
85
apps/account-ui/src/resources/ResourceToolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
324
apps/account-ui/src/resources/ResourcesTab.tsx
Normal file
324
apps/account-ui/src/resources/ResourcesTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
205
apps/account-ui/src/resources/ShareTheResource.tsx
Normal file
205
apps/account-ui/src/resources/ShareTheResource.tsx
Normal 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>
|
||||
);
|
||||
};
|
24
apps/account-ui/src/resources/SharedWith.tsx
Normal file
24
apps/account-ui/src/resources/SharedWith.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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={<PageNav />}
|
||||
isManagedSidebar
|
||||
>
|
||||
<AlertProvider>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</AlertProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
|
2
apps/account-ui/src/utils/isRecord.ts
Normal file
2
apps/account-ui/src/utils/isRecord.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
Loading…
Reference in a new issue