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",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
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;
|
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;
|
||||||
|
}
|
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 { 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")}>
|
||||||
|
|
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";
|
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";
|
||||||
|
|
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;
|
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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
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