Use user profile fields in account ui (#4488)

This commit is contained in:
Erik Jan de Wit 2023-03-02 13:54:34 +01:00 committed by GitHub
parent 6da66d8456
commit 447ef677d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 239 additions and 52 deletions

View file

@ -14,6 +14,7 @@
"i18next-http-backend": "^2.1.1", "i18next-http-backend": "^2.1.1",
"keycloak-js": "999.0.0-dev", "keycloak-js": "999.0.0-dev",
"keycloak-masthead": "999.0.0-dev", "keycloak-masthead": "999.0.0-dev",
"lodash-es": "^4.17.21",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.43.1", "react-hook-form": "^7.43.1",
@ -22,6 +23,7 @@
"ui-shared": "999.0.0-dev" "ui-shared": "999.0.0-dev"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash-es": "^4.17.6",
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@vitejs/plugin-react-swc": "^3.2.0", "@vitejs/plugin-react-swc": "^3.2.0",

View file

@ -2,6 +2,8 @@
"accept": "Accept", "accept": "Accept",
"accessGrantedOn": "Access granted on", "accessGrantedOn": "Access granted on",
"accountSecurity": "Account security", "accountSecurity": "Account security",
"accountUpdatedError": "Could not update account due to validation errors",
"accountUpdatedMessage": "Your account has been updated.",
"add": "Add", "add": "Add",
"application": "Application", "application": "Application",
"applicationDetails": "Application details", "applicationDetails": "Application details",
@ -11,6 +13,7 @@
"avatar": "Avatar", "avatar": "Avatar",
"basic-authentication": "Basic authentication", "basic-authentication": "Basic authentication",
"cancel": "Cancel", "cancel": "Cancel",
"choose": "Choose...",
"client": "Client", "client": "Client",
"clients": "Clients", "clients": "Clients",
"close": "Close", "close": "Close",
@ -20,11 +23,34 @@
"device-activity": "Device activity", "device-activity": "Device activity",
"deviceActivity": "Device activity", "deviceActivity": "Device activity",
"directMembership": "Direct membership", "directMembership": "Direct membership",
"doCancel": "Cancel",
"doDeny": "Deny", "doDeny": "Deny",
"done": "Done", "done": "Done",
"doSave": "Save",
"doSignOut": "Sign out", "doSignOut": "Sign out",
"edit": "Edit", "edit": "Edit",
"editTheResource": "Share the resource - {{0}}", "editTheResource": "Share the resource - {{0}}",
"email": "Email",
"error-empty": "Please specify value of '{{0}}'.",
"error-invalid-blank": "Please specify value of '{{0}}'.",
"error-invalid-date": "'{{0}}' is invalid date.",
"error-invalid-email": "Invalid email address.",
"error-invalid-length-too-long": "'{{0}}' must have maximal length of {{2}}.",
"error-invalid-length-too-short": "'{{0}}' must have minimal length of {{1}}.",
"error-invalid-length": "'{{0}}' must have a length between {{1}} and {{2}}.",
"error-invalid-number": "'{{0}}' is invalid number.",
"error-invalid-uri-fragment": "'{{0}}' is invalid URL fragment.",
"error-invalid-uri-scheme": "'{{0}}' has invalid URL scheme.",
"error-invalid-uri": "'{{0}}' is invalid URL.",
"error-invalid-value": "'{{0}}' has invalid value.",
"error-number-out-of-range-too-big": "'{{0}}' must have maximal value of {{2}}.",
"error-number-out-of-range-too-small": "'{{0}}' must have minimal value of {{1}}.",
"error-number-out-of-range": "'{{0}}' must be a number between {{1}} and {{2}}.",
"error-pattern-no-match": "'{{0}}' doesn't match required format.",
"error-person-name-invalid-character": "'{{0}}' contains invalid character.",
"error-user-attribute-required": "Please specify '{{0}}'.",
"error-username-invalid-character": "'{{0}}' contains invalid character.",
"errorRemovedMessage": "Could not remove {{userLabel}} due to: {{error}}",
"errorSignOutMessage": "Could not be signed out: {{error}}", "errorSignOutMessage": "Could not be signed out: {{error}}",
"expires": "Expires", "expires": "Expires",
"filterByName": "Filter By Name ...", "filterByName": "Filter By Name ...",
@ -69,6 +95,8 @@
"privacyPolicy": "Privacy policy", "privacyPolicy": "Privacy policy",
"refreshPage": "Refresh the page", "refreshPage": "Refresh the page",
"removeButton": "Remove access", "removeButton": "Remove access",
"removeConsentError": "Could not remove consent due to: {{error}}",
"removeConsentSuccess": "Successfully removed consent",
"removeCred": "Remove {{0}}", "removeCred": "Remove {{0}}",
"removeCredAriaLabel": "Remove credential", "removeCredAriaLabel": "Remove credential",
"removeModalMessage": "This will remove the currently granted access permission for {{0}}. You will need to grant access again if you want to use this app.", "removeModalMessage": "This will remove the currently granted access permission for {{0}}. You will need to grant access again if you want to use this app.",
@ -82,9 +110,12 @@
"resourceSharedWith_one": "Resource is shared with <0>{{username}}</0>", "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_other": "Resource is shared with <0>{{username}}</0> and <1>{{other}}</1> other users",
"resourceSharedWith_zero": "This resource is not shared.", "resourceSharedWith_zero": "This resource is not shared.",
"selectOne": "Select an option",
"setUpNew": "Set up {{0}}", "setUpNew": "Set up {{0}}",
"share": "Share", "share": "Share",
"sharedWithMe": "Shared with Me", "sharedWithMe": "Shared with Me",
"shareError": "Could not share the resource due to: {{error}}",
"shareSuccess": "Resource successfully shared.",
"shareTheResource": "Share the resource - {{0}}", "shareTheResource": "Share the resource - {{0}}",
"shareUser": "Add users to share your resource with", "shareUser": "Add users to share your resource with",
"shareWith": "Share with ", "shareWith": "Share with ",
@ -103,6 +134,7 @@
"started": "Started", "started": "Started",
"status": "Status", "status": "Status",
"stopUsingCred": "Stop using {{0}}?", "stopUsingCred": "Stop using {{0}}?",
"successRemovedMessage": "{{userLabel}} was removed.",
"systemDefined": "System defined", "systemDefined": "System defined",
"termsOfService": "Terms of service", "termsOfService": "Terms of service",
"thirdPartyApp": "Third-party", "thirdPartyApp": "Third-party",
@ -116,8 +148,12 @@
"unLinkError": "Could not unlink due to: {{error}}", "unLinkError": "Could not unlink due to: {{error}}",
"unLinkSuccess": "Successfully unlinked account", "unLinkSuccess": "Successfully unlinked account",
"unShare": "Unshare all", "unShare": "Unshare all",
"unShareError": "Could not un-share the resource due to: {{error}}",
"unShareSuccess": "Resource successfully un-shared.",
"update": "Update", "update": "Update",
"updateCredAriaLabel": "Update credential", "updateCredAriaLabel": "Update credential",
"updateError": "Could not update the resource due to: {{error}}",
"updateSuccess": "Resource successfully updated.",
"user": "User", "user": "User",
"username": "Username", "username": "Username",
"usernamePlaceholder": "Username or email", "usernamePlaceholder": "Username or email",

View file

@ -28,9 +28,9 @@ export const AccountRow = ({ account, isLinked = false }: AccountRowProps) => {
const unLink = async (account: LinkedAccountRepresentation) => { const unLink = async (account: LinkedAccountRepresentation) => {
try { try {
await unLinkAccount(account); await unLinkAccount(account);
addAlert("unLinkSuccess"); addAlert(t("unLinkSuccess"));
} catch (error) { } catch (error) {
addError("unLinkError", error); addError(t("unLinkError", { error }).toString());
} }
}; };
@ -39,7 +39,7 @@ export const AccountRow = ({ account, isLinked = false }: AccountRowProps) => {
const { accountLinkUri } = await linkAccount(account); const { accountLinkUri } = await linkAccount(account);
location.href = accountLinkUri; location.href = accountLinkUri;
} catch (error) { } catch (error) {
addError("linkError", error); addError(t("linkError", { error }).toString());
} }
}; };

View file

@ -76,7 +76,7 @@ const DeviceActivity = () => {
addAlert(t("signedOutSession", [session.browser, device.os])); addAlert(t("signedOutSession", [session.browser, device.os]));
refresh(); refresh();
} catch (error) { } catch (error) {
addError("errorSignOutMessage", error); addError(t("errorSignOutMessage", { error }).toString());
} }
}; };

View file

@ -185,10 +185,19 @@ const SigningIn = () => {
onContinue={async () => { onContinue={async () => {
try { try {
await deleteCredentials(meta.credential); await deleteCredentials(meta.credential);
addAlert("successRemovedMessage"); addAlert(
t("successRemovedMessage", {
userLabel: label(meta.credential),
})
);
refresh(); refresh();
} catch (error) { } catch (error) {
addError("errorRemovedMessage", error); addError(
t("errorRemovedMessage", {
userLabel: label(meta.credential),
error,
}).toString()
);
} }
}} }}
/> />

View file

@ -25,10 +25,21 @@ export type PaginationParams = {
export async function getPersonalInfo({ export async function getPersonalInfo({
signal, signal,
}: CallOptions = {}): Promise<UserRepresentation> { }: CallOptions = {}): Promise<UserRepresentation> {
const response = await request("/", { signal }); const response = await request("/?userProfileMetadata=true", { signal });
return parseResponse<UserRepresentation>(response); return parseResponse<UserRepresentation>(response);
} }
export async function savePersonalInfo(
info: UserRepresentation
): Promise<void> {
const response = await request("/", { body: info, method: "POST" });
if (!response.ok) {
const { errors } = await response.json();
throw errors;
}
return undefined;
}
export async function getPermissionRequests( export async function getPermissionRequests(
resourceId: string, resourceId: string,
{ signal }: CallOptions = {} { signal }: CallOptions = {}

View file

@ -59,9 +59,9 @@ const Applications = () => {
try { try {
await deleteConsent(id); await deleteConsent(id);
refresh(); refresh();
addAlert("removeConsentSuccess"); addAlert(t("removeConsentSuccess"));
} catch (error) { } catch (error) {
addError("removeConsentError", error); addError(t("removeConsentError", { error }).toString());
} }
}; };

View file

@ -0,0 +1,88 @@
import { FormGroup, Select, SelectOption } from "@patternfly/react-core";
import { TFuncKey } from "i18next";
import { get } from "lodash-es";
import { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { KeycloakTextInput } from "ui-shared";
import { UserProfileAttributeMetadata } from "../api/representations";
import { fieldName, isBundleKey, unWrap } from "./PersonalInfo";
type FormFieldProps = {
attribute: UserProfileAttributeMetadata;
};
export const FormField = ({ attribute }: FormFieldProps) => {
const { t } = useTranslation();
const {
formState: { errors },
register,
control,
} = useFormContext();
const [open, setOpen] = useState(false);
const toggle = () => setOpen(!open);
const isSelect = (attribute: UserProfileAttributeMetadata) =>
Object.hasOwn(attribute.validators, "options");
return (
<FormGroup
key={attribute.name}
label={
(isBundleKey(attribute.displayName)
? t(unWrap(attribute.displayName) as TFuncKey)
: attribute.displayName) || attribute.name
}
fieldId={attribute.name}
isRequired={attribute.required}
validated={get(errors, fieldName(attribute.name)) ? "error" : "default"}
helperTextInvalid={
get(errors, fieldName(attribute.name))?.message as string
}
>
{isSelect(attribute) ? (
<Controller
name={fieldName(attribute.name)}
defaultValue=""
control={control}
render={({ field }) => (
<Select
toggleId={attribute.name}
onToggle={toggle}
onSelect={(_, value) => {
field.onChange(value.toString());
toggle();
}}
selections={field.value}
variant="single"
aria-label={t("selectOne")}
isOpen={open}
>
{[
<SelectOption key="empty" value="">
{t("choose")}
</SelectOption>,
...(
attribute.validators.options as { options: string[] }
).options.map((option) => (
<SelectOption
selected={field.value === option}
key={option}
value={option}
>
{option}
</SelectOption>
)),
]}
</Select>
)}
/>
) : (
<KeycloakTextInput
id={attribute.name}
{...register(fieldName(attribute.name))}
/>
)}
</FormGroup>
);
};

View file

@ -1,36 +1,84 @@
import { Form } from "@patternfly/react-core"; import { ActionGroup, Button, Form } from "@patternfly/react-core";
import { useForm } from "react-hook-form"; import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAlerts } from "ui-shared";
import { TextControl } from "ui-shared"; import { getPersonalInfo, savePersonalInfo } from "../api/methods";
import { getPersonalInfo } from "../api/methods"; import {
import { UserRepresentation } from "../api/representations"; UserProfileMetadata,
UserRepresentation,
} from "../api/representations";
import { Page } from "../components/page/Page"; import { Page } from "../components/page/Page";
import { usePromise } from "../utils/usePromise"; import { usePromise } from "../utils/usePromise";
import { FormField } from "./FormField";
type FieldError = {
field: string;
errorMessage: string;
params: string[];
};
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
export const isBundleKey = (key?: string) => key?.includes("${");
export const unWrap = (key: string) => key.substring(2, key.length - 1);
export const isRootAttribute = (attr?: string) =>
attr && ROOT_ATTRIBUTES.includes(attr);
export const fieldName = (name: string) =>
`${isRootAttribute(name) ? "" : "attributes."}${name}`;
const PersonalInfo = () => { const PersonalInfo = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control, reset } = useForm<UserRepresentation>({ const [userProfileMetadata, setUserProfileMetadata] =
mode: "onChange", useState<UserProfileMetadata>();
}); const form = useForm<UserRepresentation>({ mode: "onChange" });
const { handleSubmit, reset, setError } = form;
const { addAlert, addError } = useAlerts();
usePromise((signal) => getPersonalInfo({ signal }), reset); usePromise(
(signal) => getPersonalInfo({ signal }),
(personalInfo) => {
setUserProfileMetadata(personalInfo.userProfileMetadata);
reset(personalInfo);
}
);
const onSubmit = async (user: UserRepresentation) => {
try {
await savePersonalInfo(user);
addAlert(t("accountUpdatedMessage"));
} catch (error) {
addError(t("accountUpdatedError").toString());
(error as FieldError[]).forEach((e) => {
const params = Object.assign(
{},
e.params.map((p) => (isBundleKey(p) ? unWrap(p) : p))
);
setError(fieldName(e.field) as keyof UserRepresentation, {
message: t(e.errorMessage, { ...params, defaultValue: e.field }),
type: "server",
});
});
}
};
return ( return (
<Page title={t("personalInfo")} description={t("personalInfoDescription")}> <Page title={t("personalInfo")} description={t("personalInfoDescription")}>
<Form isHorizontal> <Form isHorizontal onSubmit={handleSubmit(onSubmit)}>
<TextControl <FormProvider {...form}>
control={control} {(userProfileMetadata?.attributes || []).map((attribute) => (
name="username" <FormField key={attribute.name} attribute={attribute} />
rules={{ maxLength: 254, required: true }} ))}
label={t("username")} </FormProvider>
/> <ActionGroup>
<TextControl <Button type="submit" id="save-btn" variant="primary">
control={control} {t("doSave")}
name="firstName" </Button>
label={t("firstName")} <Button id="cancel-btn" variant="link" onClick={() => reset()}>
/> {t("doCancel")}
<TextControl control={control} name="lastName" label={t("lastName")} /> </Button>
</ActionGroup>
</Form> </Form>
</Page> </Page>
); );

View file

@ -42,10 +42,10 @@ export const EditTheResource = ({
updatePermissions(resource._id, [permission]) updatePermissions(resource._id, [permission])
) )
); );
addAlert("updateSuccess"); addAlert(t("updateSuccess"));
onClose(); onClose();
} catch (error) { } catch (error) {
addError("updateError", error); addError(t("updateError", { error }).toString());
} }
}; };

View file

@ -55,11 +55,11 @@ export const PermissionRequest = ({
? [...(scopes as string[]), ...(shareRequest.scopes as string[])] ? [...(scopes as string[]), ...(shareRequest.scopes as string[])]
: scopes : scopes
); );
addAlert("shareSuccess"); addAlert(t("shareSuccess"));
toggle(); toggle();
refresh(); refresh();
} catch (error) { } catch (error) {
addError("shareError", error); addError(t("shareError", { error }).toString());
} }
}; };

View file

@ -106,9 +106,9 @@ export const ResourcesTab = () => {
)!; )!;
await updatePermissions(resource._id, permissions); await updatePermissions(resource._id, permissions);
setDetails({}); setDetails({});
addAlert("unShareSuccess"); addAlert(t("unShareSuccess"));
} catch (error) { } catch (error) {
addError("updateError", error); addError(t("unShareError", { error }).toString());
} }
}; };

View file

@ -82,10 +82,10 @@ export const ShareTheResource = ({
updateRequest(resource._id, username, permissions) updateRequest(resource._id, username, permissions)
) )
); );
addAlert("shareSuccess"); addAlert(t("shareSuccess"));
onClose(); onClose();
} catch (error) { } catch (error) {
addError("shareError", error); addError(t("shareError", { error }).toString());
} }
reset({}); reset({});
}; };

View file

@ -5,7 +5,6 @@ import {
AlertVariant, AlertVariant,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { createContext, PropsWithChildren, useContext, useState } from "react"; import { createContext, PropsWithChildren, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
export type AddAlertFunction = ( export type AddAlertFunction = (
message: string, message: string,
@ -13,7 +12,7 @@ export type AddAlertFunction = (
description?: string description?: string
) => void; ) => void;
export type AddErrorFunction = (message: string, error: any) => void; export type AddErrorFunction = (message: string) => void;
export type AlertProps = { export type AlertProps = {
addAlert: AddAlertFunction; addAlert: AddAlertFunction;
@ -32,7 +31,6 @@ export type AlertType = {
}; };
export const AlertProvider = ({ children }: PropsWithChildren) => { export const AlertProvider = ({ children }: PropsWithChildren) => {
const { t } = useTranslation();
const [alerts, setAlerts] = useState<AlertType[]>([]); const [alerts, setAlerts] = useState<AlertType[]>([]);
const hideAlert = (id: string) => { const hideAlert = (id: string) => {
@ -47,8 +45,7 @@ export const AlertProvider = ({ children }: PropsWithChildren) => {
setAlerts([ setAlerts([
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
//@ts-ignore message,
message: t(message),
variant, variant,
description, description,
}, },
@ -56,14 +53,8 @@ export const AlertProvider = ({ children }: PropsWithChildren) => {
]); ]);
}; };
const addError = (message: string, error: Error | string) => { const addError = (message: string) => {
addAlert( addAlert(message, AlertVariant.danger);
//@ts-ignore
t(message, {
error,
}),
AlertVariant.danger
);
}; };
return ( return (

2
package-lock.json generated
View file

@ -43,6 +43,7 @@
"i18next-http-backend": "^2.1.1", "i18next-http-backend": "^2.1.1",
"keycloak-js": "999.0.0-dev", "keycloak-js": "999.0.0-dev",
"keycloak-masthead": "999.0.0-dev", "keycloak-masthead": "999.0.0-dev",
"lodash-es": "^4.17.21",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.43.1", "react-hook-form": "^7.43.1",
@ -51,6 +52,7 @@
"ui-shared": "999.0.0-dev" "ui-shared": "999.0.0-dev"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash-es": "^4.17.6",
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@vitejs/plugin-react-swc": "^3.2.0", "@vitejs/plugin-react-swc": "^3.2.0",