diff --git a/apps/account-ui/package.json b/apps/account-ui/package.json index 02a73c9ae6..ce64b8170a 100644 --- a/apps/account-ui/package.json +++ b/apps/account-ui/package.json @@ -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", diff --git a/apps/account-ui/public/locales/en/translation.json b/apps/account-ui/public/locales/en/translation.json index bcdfaac96b..1019f7bdd0 100644 --- a/apps/account-ui/public/locales/en/translation.json +++ b/apps/account-ui/public/locales/en/translation.json @@ -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}}", "resourceSharedWith_other": "Resource is shared with <0>{{username}} and <1>{{other}} 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", diff --git a/apps/account-ui/src/account-security/AccountRow.tsx b/apps/account-ui/src/account-security/AccountRow.tsx index 32210c177c..01c14d17af 100644 --- a/apps/account-ui/src/account-security/AccountRow.tsx +++ b/apps/account-ui/src/account-security/AccountRow.tsx @@ -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()); } }; diff --git a/apps/account-ui/src/account-security/DeviceActivity.tsx b/apps/account-ui/src/account-security/DeviceActivity.tsx index 0c9cc38283..532e0957aa 100644 --- a/apps/account-ui/src/account-security/DeviceActivity.tsx +++ b/apps/account-ui/src/account-security/DeviceActivity.tsx @@ -76,7 +76,7 @@ const DeviceActivity = () => { addAlert(t("signedOutSession", [session.browser, device.os])); refresh(); } catch (error) { - addError("errorSignOutMessage", error); + addError(t("errorSignOutMessage", { error }).toString()); } }; diff --git a/apps/account-ui/src/account-security/SigningIn.tsx b/apps/account-ui/src/account-security/SigningIn.tsx index 8d1a482fd7..add3c43a0b 100644 --- a/apps/account-ui/src/account-security/SigningIn.tsx +++ b/apps/account-ui/src/account-security/SigningIn.tsx @@ -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() + ); } }} /> diff --git a/apps/account-ui/src/api/methods.ts b/apps/account-ui/src/api/methods.ts index b0ac4547a2..62fd43fda4 100644 --- a/apps/account-ui/src/api/methods.ts +++ b/apps/account-ui/src/api/methods.ts @@ -25,10 +25,21 @@ export type PaginationParams = { export async function getPersonalInfo({ signal, }: CallOptions = {}): Promise { - const response = await request("/", { signal }); + const response = await request("/?userProfileMetadata=true", { signal }); return parseResponse(response); } +export async function savePersonalInfo( + info: UserRepresentation +): Promise { + 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 = {} diff --git a/apps/account-ui/src/applications/Applications.tsx b/apps/account-ui/src/applications/Applications.tsx index 56c9cd9dee..322b79f610 100644 --- a/apps/account-ui/src/applications/Applications.tsx +++ b/apps/account-ui/src/applications/Applications.tsx @@ -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()); } }; diff --git a/apps/account-ui/src/personal-info/FormField.tsx b/apps/account-ui/src/personal-info/FormField.tsx new file mode 100644 index 0000000000..8a4a2811e5 --- /dev/null +++ b/apps/account-ui/src/personal-info/FormField.tsx @@ -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 ( + + {isSelect(attribute) ? ( + ( + + )} + /> + ) : ( + + )} + + ); +}; diff --git a/apps/account-ui/src/personal-info/PersonalInfo.tsx b/apps/account-ui/src/personal-info/PersonalInfo.tsx index f746a1c82f..87f394742c 100644 --- a/apps/account-ui/src/personal-info/PersonalInfo.tsx +++ b/apps/account-ui/src/personal-info/PersonalInfo.tsx @@ -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({ - mode: "onChange", - }); + const [userProfileMetadata, setUserProfileMetadata] = + useState(); + const form = useForm({ 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 ( -
- - - + + + {(userProfileMetadata?.attributes || []).map((attribute) => ( + + ))} + + + + +
); diff --git a/apps/account-ui/src/resources/EditTheResource.tsx b/apps/account-ui/src/resources/EditTheResource.tsx index 655bc4fb07..6f02e81492 100644 --- a/apps/account-ui/src/resources/EditTheResource.tsx +++ b/apps/account-ui/src/resources/EditTheResource.tsx @@ -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()); } }; diff --git a/apps/account-ui/src/resources/PermissionRequest.tsx b/apps/account-ui/src/resources/PermissionRequest.tsx index 8c84703a10..eadd302c91 100644 --- a/apps/account-ui/src/resources/PermissionRequest.tsx +++ b/apps/account-ui/src/resources/PermissionRequest.tsx @@ -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()); } }; diff --git a/apps/account-ui/src/resources/ResourcesTab.tsx b/apps/account-ui/src/resources/ResourcesTab.tsx index cbb5679c03..3a7811e6c3 100644 --- a/apps/account-ui/src/resources/ResourcesTab.tsx +++ b/apps/account-ui/src/resources/ResourcesTab.tsx @@ -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()); } }; diff --git a/apps/account-ui/src/resources/ShareTheResource.tsx b/apps/account-ui/src/resources/ShareTheResource.tsx index 005bc710eb..383b80f133 100644 --- a/apps/account-ui/src/resources/ShareTheResource.tsx +++ b/apps/account-ui/src/resources/ShareTheResource.tsx @@ -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({}); }; diff --git a/libs/ui-shared/src/alerts/Alerts.tsx b/libs/ui-shared/src/alerts/Alerts.tsx index a7c7dea00e..5f3aa65092 100644 --- a/libs/ui-shared/src/alerts/Alerts.tsx +++ b/libs/ui-shared/src/alerts/Alerts.tsx @@ -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([]); 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 ( diff --git a/package-lock.json b/package-lock.json index 2e7b39afab..8cf852ff3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",