Use user profile fields in account ui (#4488)
This commit is contained in:
parent
6da66d8456
commit
447ef677d6
15 changed files with 239 additions and 52 deletions
|
@ -14,6 +14,7 @@
|
|||
"i18next-http-backend": "^2.1.1",
|
||||
"keycloak-js": "999.0.0-dev",
|
||||
"keycloak-masthead": "999.0.0-dev",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.1",
|
||||
|
@ -22,6 +23,7 @@
|
|||
"ui-shared": "999.0.0-dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react-swc": "^3.2.0",
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
"accept": "Accept",
|
||||
"accessGrantedOn": "Access granted on",
|
||||
"accountSecurity": "Account security",
|
||||
"accountUpdatedError": "Could not update account due to validation errors",
|
||||
"accountUpdatedMessage": "Your account has been updated.",
|
||||
"add": "Add",
|
||||
"application": "Application",
|
||||
"applicationDetails": "Application details",
|
||||
|
@ -11,6 +13,7 @@
|
|||
"avatar": "Avatar",
|
||||
"basic-authentication": "Basic authentication",
|
||||
"cancel": "Cancel",
|
||||
"choose": "Choose...",
|
||||
"client": "Client",
|
||||
"clients": "Clients",
|
||||
"close": "Close",
|
||||
|
@ -20,11 +23,34 @@
|
|||
"device-activity": "Device activity",
|
||||
"deviceActivity": "Device activity",
|
||||
"directMembership": "Direct membership",
|
||||
"doCancel": "Cancel",
|
||||
"doDeny": "Deny",
|
||||
"done": "Done",
|
||||
"doSave": "Save",
|
||||
"doSignOut": "Sign out",
|
||||
"edit": "Edit",
|
||||
"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}}",
|
||||
"expires": "Expires",
|
||||
"filterByName": "Filter By Name ...",
|
||||
|
@ -69,6 +95,8 @@
|
|||
"privacyPolicy": "Privacy policy",
|
||||
"refreshPage": "Refresh the page",
|
||||
"removeButton": "Remove access",
|
||||
"removeConsentError": "Could not remove consent due to: {{error}}",
|
||||
"removeConsentSuccess": "Successfully removed consent",
|
||||
"removeCred": "Remove {{0}}",
|
||||
"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.",
|
||||
|
@ -82,9 +110,12 @@
|
|||
"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.",
|
||||
"selectOne": "Select an option",
|
||||
"setUpNew": "Set up {{0}}",
|
||||
"share": "Share",
|
||||
"sharedWithMe": "Shared with Me",
|
||||
"shareError": "Could not share the resource due to: {{error}}",
|
||||
"shareSuccess": "Resource successfully shared.",
|
||||
"shareTheResource": "Share the resource - {{0}}",
|
||||
"shareUser": "Add users to share your resource with",
|
||||
"shareWith": "Share with ",
|
||||
|
@ -103,6 +134,7 @@
|
|||
"started": "Started",
|
||||
"status": "Status",
|
||||
"stopUsingCred": "Stop using {{0}}?",
|
||||
"successRemovedMessage": "{{userLabel}} was removed.",
|
||||
"systemDefined": "System defined",
|
||||
"termsOfService": "Terms of service",
|
||||
"thirdPartyApp": "Third-party",
|
||||
|
@ -116,8 +148,12 @@
|
|||
"unLinkError": "Could not unlink due to: {{error}}",
|
||||
"unLinkSuccess": "Successfully unlinked account",
|
||||
"unShare": "Unshare all",
|
||||
"unShareError": "Could not un-share the resource due to: {{error}}",
|
||||
"unShareSuccess": "Resource successfully un-shared.",
|
||||
"update": "Update",
|
||||
"updateCredAriaLabel": "Update credential",
|
||||
"updateError": "Could not update the resource due to: {{error}}",
|
||||
"updateSuccess": "Resource successfully updated.",
|
||||
"user": "User",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Username or email",
|
||||
|
|
|
@ -28,9 +28,9 @@ export const AccountRow = ({ account, isLinked = false }: AccountRowProps) => {
|
|||
const unLink = async (account: LinkedAccountRepresentation) => {
|
||||
try {
|
||||
await unLinkAccount(account);
|
||||
addAlert("unLinkSuccess");
|
||||
addAlert(t("unLinkSuccess"));
|
||||
} 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);
|
||||
location.href = accountLinkUri;
|
||||
} catch (error) {
|
||||
addError("linkError", error);
|
||||
addError(t("linkError", { error }).toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -76,7 +76,7 @@ const DeviceActivity = () => {
|
|||
addAlert(t("signedOutSession", [session.browser, device.os]));
|
||||
refresh();
|
||||
} catch (error) {
|
||||
addError("errorSignOutMessage", error);
|
||||
addError(t("errorSignOutMessage", { error }).toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -185,10 +185,19 @@ const SigningIn = () => {
|
|||
onContinue={async () => {
|
||||
try {
|
||||
await deleteCredentials(meta.credential);
|
||||
addAlert("successRemovedMessage");
|
||||
addAlert(
|
||||
t("successRemovedMessage", {
|
||||
userLabel: label(meta.credential),
|
||||
})
|
||||
);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
addError("errorRemovedMessage", error);
|
||||
addError(
|
||||
t("errorRemovedMessage", {
|
||||
userLabel: label(meta.credential),
|
||||
error,
|
||||
}).toString()
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -25,10 +25,21 @@ export type PaginationParams = {
|
|||
export async function getPersonalInfo({
|
||||
signal,
|
||||
}: CallOptions = {}): Promise<UserRepresentation> {
|
||||
const response = await request("/", { signal });
|
||||
const response = await request("/?userProfileMetadata=true", { signal });
|
||||
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(
|
||||
resourceId: string,
|
||||
{ signal }: CallOptions = {}
|
||||
|
|
|
@ -59,9 +59,9 @@ const Applications = () => {
|
|||
try {
|
||||
await deleteConsent(id);
|
||||
refresh();
|
||||
addAlert("removeConsentSuccess");
|
||||
addAlert(t("removeConsentSuccess"));
|
||||
} catch (error) {
|
||||
addError("removeConsentError", error);
|
||||
addError(t("removeConsentError", { error }).toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
88
apps/account-ui/src/personal-info/FormField.tsx
Normal file
88
apps/account-ui/src/personal-info/FormField.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -1,36 +1,84 @@
|
|||
import { Form } from "@patternfly/react-core";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ActionGroup, Button, Form } from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAlerts } from "ui-shared";
|
||||
|
||||
import { TextControl } from "ui-shared";
|
||||
import { getPersonalInfo } from "../api/methods";
|
||||
import { UserRepresentation } from "../api/representations";
|
||||
import { getPersonalInfo, savePersonalInfo } from "../api/methods";
|
||||
import {
|
||||
UserProfileMetadata,
|
||||
UserRepresentation,
|
||||
} from "../api/representations";
|
||||
import { Page } from "../components/page/Page";
|
||||
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 { t } = useTranslation();
|
||||
const { control, reset } = useForm<UserRepresentation>({
|
||||
mode: "onChange",
|
||||
});
|
||||
const [userProfileMetadata, setUserProfileMetadata] =
|
||||
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 (
|
||||
<Page title={t("personalInfo")} description={t("personalInfoDescription")}>
|
||||
<Form isHorizontal>
|
||||
<TextControl
|
||||
control={control}
|
||||
name="username"
|
||||
rules={{ maxLength: 254, required: true }}
|
||||
label={t("username")}
|
||||
/>
|
||||
<TextControl
|
||||
control={control}
|
||||
name="firstName"
|
||||
label={t("firstName")}
|
||||
/>
|
||||
<TextControl control={control} name="lastName" label={t("lastName")} />
|
||||
<Form isHorizontal onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormProvider {...form}>
|
||||
{(userProfileMetadata?.attributes || []).map((attribute) => (
|
||||
<FormField key={attribute.name} attribute={attribute} />
|
||||
))}
|
||||
</FormProvider>
|
||||
<ActionGroup>
|
||||
<Button type="submit" id="save-btn" variant="primary">
|
||||
{t("doSave")}
|
||||
</Button>
|
||||
<Button id="cancel-btn" variant="link" onClick={() => reset()}>
|
||||
{t("doCancel")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</Form>
|
||||
</Page>
|
||||
);
|
||||
|
|
|
@ -42,10 +42,10 @@ export const EditTheResource = ({
|
|||
updatePermissions(resource._id, [permission])
|
||||
)
|
||||
);
|
||||
addAlert("updateSuccess");
|
||||
addAlert(t("updateSuccess"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
addError("updateError", error);
|
||||
addError(t("updateError", { error }).toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -55,11 +55,11 @@ export const PermissionRequest = ({
|
|||
? [...(scopes as string[]), ...(shareRequest.scopes as string[])]
|
||||
: scopes
|
||||
);
|
||||
addAlert("shareSuccess");
|
||||
addAlert(t("shareSuccess"));
|
||||
toggle();
|
||||
refresh();
|
||||
} catch (error) {
|
||||
addError("shareError", error);
|
||||
addError(t("shareError", { error }).toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -106,9 +106,9 @@ export const ResourcesTab = () => {
|
|||
)!;
|
||||
await updatePermissions(resource._id, permissions);
|
||||
setDetails({});
|
||||
addAlert("unShareSuccess");
|
||||
addAlert(t("unShareSuccess"));
|
||||
} catch (error) {
|
||||
addError("updateError", error);
|
||||
addError(t("unShareError", { error }).toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -82,10 +82,10 @@ export const ShareTheResource = ({
|
|||
updateRequest(resource._id, username, permissions)
|
||||
)
|
||||
);
|
||||
addAlert("shareSuccess");
|
||||
addAlert(t("shareSuccess"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
addError("shareError", error);
|
||||
addError(t("shareError", { error }).toString());
|
||||
}
|
||||
reset({});
|
||||
};
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
AlertVariant,
|
||||
} from "@patternfly/react-core";
|
||||
import { createContext, PropsWithChildren, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type AddAlertFunction = (
|
||||
message: string,
|
||||
|
@ -13,7 +12,7 @@ export type AddAlertFunction = (
|
|||
description?: string
|
||||
) => void;
|
||||
|
||||
export type AddErrorFunction = (message: string, error: any) => void;
|
||||
export type AddErrorFunction = (message: string) => void;
|
||||
|
||||
export type AlertProps = {
|
||||
addAlert: AddAlertFunction;
|
||||
|
@ -32,7 +31,6 @@ export type AlertType = {
|
|||
};
|
||||
|
||||
export const AlertProvider = ({ children }: PropsWithChildren) => {
|
||||
const { t } = useTranslation();
|
||||
const [alerts, setAlerts] = useState<AlertType[]>([]);
|
||||
|
||||
const hideAlert = (id: string) => {
|
||||
|
@ -47,8 +45,7 @@ export const AlertProvider = ({ children }: PropsWithChildren) => {
|
|||
setAlerts([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
//@ts-ignore
|
||||
message: t(message),
|
||||
message,
|
||||
variant,
|
||||
description,
|
||||
},
|
||||
|
@ -56,14 +53,8 @@ export const AlertProvider = ({ children }: PropsWithChildren) => {
|
|||
]);
|
||||
};
|
||||
|
||||
const addError = (message: string, error: Error | string) => {
|
||||
addAlert(
|
||||
//@ts-ignore
|
||||
t(message, {
|
||||
error,
|
||||
}),
|
||||
AlertVariant.danger
|
||||
);
|
||||
const addError = (message: string) => {
|
||||
addAlert(message, AlertVariant.danger);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -43,6 +43,7 @@
|
|||
"i18next-http-backend": "^2.1.1",
|
||||
"keycloak-js": "999.0.0-dev",
|
||||
"keycloak-masthead": "999.0.0-dev",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.1",
|
||||
|
@ -51,6 +52,7 @@
|
|||
"ui-shared": "999.0.0-dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react-swc": "^3.2.0",
|
||||
|
|
Loading…
Reference in a new issue