From 5d39625d82e501ea428d61549c94b50a376eeaa8 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Thu, 1 Feb 2024 16:46:11 +0100 Subject: [PATCH] account library (#26373) * initial version Signed-off-by: Erik Jan de Wit * better init Signed-off-by: Erik Jan de Wit * added more components Signed-off-by: Erik Jan de Wit * moved to public Signed-off-by: Erik Jan de Wit * use environment var to enter library mode Signed-off-by: Erik Jan de Wit * added export field Signed-off-by: Erik Jan de Wit --------- Signed-off-by: Erik Jan de Wit --- js/apps/account-ui/package.json | 8 ++ .../src/account-security/AccountRow.tsx | 6 +- .../src/account-security/DeviceActivity.tsx | 15 +-- .../src/account-security/LinkedAccounts.tsx | 8 +- .../src/account-security/SigningIn.tsx | 16 ++- js/apps/account-ui/src/api.ts | 82 +++++----------- js/apps/account-ui/src/api/methods.ts | 97 ++++++++++++------- js/apps/account-ui/src/api/request.ts | 53 ++++++---- .../src/applications/Applications.tsx | 8 +- .../src/content/ContentComponent.tsx | 12 ++- .../account-ui/src/content/fetchContent.ts | 5 +- js/apps/account-ui/src/groups/Groups.tsx | 6 +- js/apps/account-ui/src/index.ts | 42 ++++++++ js/apps/account-ui/src/keycloak.ts | 8 -- js/apps/account-ui/src/main.tsx | 8 +- .../src/personal-info/PersonalInfo.tsx | 15 +-- .../src/resources/EditTheResource.tsx | 4 +- .../src/resources/PermissionRequest.tsx | 5 +- .../account-ui/src/resources/Resources.tsx | 2 +- .../account-ui/src/resources/ResourcesTab.tsx | 17 +++- .../src/resources/ShareTheResource.tsx | 6 +- js/apps/account-ui/src/root/Header.tsx | 74 ++++++++++++++ .../account-ui/src/root/KeycloakContext.tsx | 77 +++++++++++++++ js/apps/account-ui/src/root/PageNav.tsx | 21 ++-- js/apps/account-ui/src/root/Root.tsx | 91 ++--------------- .../{Root.module.css => header.module.css} | 0 .../account-ui/test/account-security.spec.ts | 6 +- js/apps/account-ui/vite.config.ts | 45 ++++++--- 28 files changed, 458 insertions(+), 279 deletions(-) create mode 100644 js/apps/account-ui/src/index.ts delete mode 100644 js/apps/account-ui/src/keycloak.ts create mode 100644 js/apps/account-ui/src/root/Header.tsx create mode 100644 js/apps/account-ui/src/root/KeycloakContext.tsx rename js/apps/account-ui/src/root/{Root.module.css => header.module.css} (100%) diff --git a/js/apps/account-ui/package.json b/js/apps/account-ui/package.json index b49c9deae1..1279d5b378 100644 --- a/js/apps/account-ui/package.json +++ b/js/apps/account-ui/package.json @@ -1,6 +1,14 @@ { "name": "account-ui", "type": "module", + "main": "dist/account-ui.js", + "types": "./dist/account-ui.d.ts", + "exports": { + ".": { + "import": "./dist/account-ui.js", + "types": "./dist/account-ui.d.ts" + } + }, "scripts": { "dev": "wireit", "build": "wireit", diff --git a/js/apps/account-ui/src/account-security/AccountRow.tsx b/js/apps/account-ui/src/account-security/AccountRow.tsx index a85839c672..0b3a98fc97 100644 --- a/js/apps/account-ui/src/account-security/AccountRow.tsx +++ b/js/apps/account-ui/src/account-security/AccountRow.tsx @@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"; import { IconMapper, useAlerts } from "ui-shared"; import { linkAccount, unLinkAccount } from "../api/methods"; import { LinkedAccountRepresentation } from "../api/representations"; +import { useEnvironment } from "../root/KeycloakContext"; type AccountRowProps = { account: LinkedAccountRepresentation; @@ -23,11 +24,12 @@ type AccountRowProps = { export const AccountRow = ({ account, isLinked = false }: AccountRowProps) => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const unLink = async (account: LinkedAccountRepresentation) => { try { - await unLinkAccount(account); + await unLinkAccount(context, account); addAlert(t("unLinkSuccess")); } catch (error) { addError(t("unLinkError", { error }).toString()); @@ -36,7 +38,7 @@ export const AccountRow = ({ account, isLinked = false }: AccountRowProps) => { const link = async (account: LinkedAccountRepresentation) => { try { - const { accountLinkUri } = await linkAccount(account); + const { accountLinkUri } = await linkAccount(context, account); location.href = accountLinkUri; } catch (error) { addError(t("linkError", { error }).toString()); diff --git a/js/apps/account-ui/src/account-security/DeviceActivity.tsx b/js/apps/account-ui/src/account-security/DeviceActivity.tsx index 02a9d19955..22ccf1fd34 100644 --- a/js/apps/account-ui/src/account-security/DeviceActivity.tsx +++ b/js/apps/account-ui/src/account-security/DeviceActivity.tsx @@ -32,12 +32,13 @@ import { } from "../api/representations"; import { Page } from "../components/page/Page"; import { TFuncKey } from "../i18n"; -import { keycloak } from "../keycloak"; +import { useEnvironment } from "../root/KeycloakContext"; import { formatDate } from "../utils/formatDate"; import { usePromise } from "../utils/usePromise"; -const DeviceActivity = () => { +export const DeviceActivity = () => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const [devices, setDevices] = useState(); @@ -58,11 +59,13 @@ const DeviceActivity = () => { setDevices(devices); }; - usePromise((signal) => getDevices({ signal }), moveCurrentToTop, [key]); + usePromise((signal) => getDevices({ signal, context }), moveCurrentToTop, [ + key, + ]); const signOutAll = async () => { - await deleteSession(); - keycloak.logout(); + await deleteSession(context); + context.keycloak.logout(); }; const signOutSession = async ( @@ -70,7 +73,7 @@ const DeviceActivity = () => { device: DeviceRepresentation, ) => { try { - await deleteSession(session.id); + await deleteSession(context, session.id); addAlert( t("signedOutSession", { browser: session.browser, os: device.os }), ); diff --git a/js/apps/account-ui/src/account-security/LinkedAccounts.tsx b/js/apps/account-ui/src/account-security/LinkedAccounts.tsx index 803c7e7cbd..bae3068d69 100644 --- a/js/apps/account-ui/src/account-security/LinkedAccounts.tsx +++ b/js/apps/account-ui/src/account-security/LinkedAccounts.tsx @@ -7,15 +7,19 @@ import { EmptyRow } from "../components/datalist/EmptyRow"; import { Page } from "../components/page/Page"; import { usePromise } from "../utils/usePromise"; import { AccountRow } from "./AccountRow"; +import { useEnvironment } from "../root/KeycloakContext"; -const LinkedAccounts = () => { +export const LinkedAccounts = () => { const { t } = useTranslation(); + const context = useEnvironment(); const [accounts, setAccounts] = useState([]); const [key, setKey] = useState(1); const refresh = () => setKey(key + 1); - usePromise((signal) => getLinkedAccounts({ signal }), setAccounts, [key]); + usePromise((signal) => getLinkedAccounts({ signal, context }), setAccounts, [ + key, + ]); const linkedAccounts = useMemo( () => accounts.filter((account) => account.connected), diff --git a/js/apps/account-ui/src/account-security/SigningIn.tsx b/js/apps/account-ui/src/account-security/SigningIn.tsx index 514e47f651..2d2ba50e84 100644 --- a/js/apps/account-ui/src/account-security/SigningIn.tsx +++ b/js/apps/account-ui/src/account-security/SigningIn.tsx @@ -27,9 +27,9 @@ import { import { EmptyRow } from "../components/datalist/EmptyRow"; import { Page } from "../components/page/Page"; import { TFuncKey } from "../i18n"; -import { keycloak } from "../keycloak"; import { formatDate } from "../utils/formatDate"; import { usePromise } from "../utils/usePromise"; +import { useEnvironment } from "../root/KeycloakContext"; type MobileLinkProps = { title: string; @@ -63,16 +63,19 @@ const MobileLink = ({ title, onClick }: MobileLinkProps) => { ); }; -const SigningIn = () => { +export const SigningIn = () => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); - const { login } = keycloak; + const { login } = context.keycloak; const [credentials, setCredentials] = useState(); const [key, setKey] = useState(1); const refresh = () => setKey(key + 1); - usePromise((signal) => getCredentials({ signal }), setCredentials, [key]); + usePromise((signal) => getCredentials({ signal, context }), setCredentials, [ + key, + ]); const credentialRowCells = ( credMetadata: CredentialMetadataRepresentation, @@ -181,7 +184,10 @@ const SigningIn = () => { buttonVariant="danger" onContinue={async () => { try { - await deleteCredentials(meta.credential); + await deleteCredentials( + context, + meta.credential, + ); addAlert( t("successRemovedMessage", { userLabel: label(meta.credential), diff --git a/js/apps/account-ui/src/api.ts b/js/apps/account-ui/src/api.ts index c3b7e42299..ddc3694c7e 100644 --- a/js/apps/account-ui/src/api.ts +++ b/js/apps/account-ui/src/api.ts @@ -1,19 +1,19 @@ +import { CallOptions } from "./api/methods"; import { Links, parseLinks } from "./api/parse-links"; +import { parseResponse } from "./api/parse-response"; import { Permission, Resource, Scope } from "./api/representations"; -import { environment } from "./environment"; -import { keycloak } from "./keycloak"; -import { joinPath } from "./utils/joinPath"; +import { request } from "./api/request"; +import { KeycloakContext } from "./root/KeycloakContext"; export const fetchResources = async ( - params: RequestInit, + { signal, context }: CallOptions, requestParams: Record, shared: boolean | undefined = false, ): Promise<{ data: Resource[]; links: Links }> => { - const response = await get( - `/resources${shared ? "/shared-with-me?" : "?"}${ - shared ? "" : new URLSearchParams(requestParams) - }`, - params, + const response = await request( + `/resources${shared ? "/shared-with-me?" : "?"}`, + context, + { searchParams: shared ? requestParams : undefined, signal }, ); let links: Links; @@ -31,77 +31,39 @@ export const fetchResources = async ( }; export const fetchPermission = async ( - params: RequestInit, + { signal, context }: CallOptions, resourceId: string, ): Promise => { - const response = await request( + const response = await request( `/resources/${resourceId}/permissions`, - params, + context, + { signal }, ); - return checkResponse(response); + return parseResponse(response); }; export const updateRequest = ( + context: KeycloakContext, resourceId: string, username: string, scopes: Scope[] | string[], ) => - request(`/resources/${resourceId}/permissions`, { - method: "put", - body: JSON.stringify([{ username, scopes }]), + request(`/resources/${resourceId}/permissions`, context, { + method: "PUT", + body: [{ username, scopes }], }); export const updatePermissions = ( + context: KeycloakContext, resourceId: string, permissions: Permission[], ) => - request(`/resources/${resourceId}/permissions`, { - method: "put", - body: JSON.stringify(permissions), + request(`/resources/${resourceId}/permissions`, context, { + method: "PUT", + body: permissions, }); function checkResponse(response: T) { if (!response) throw new Error("Could not fetch"); return response; } - -async function get(path: string, params: RequestInit): Promise { - const url = joinPath( - environment.authUrl, - "realms", - environment.realm, - "account", - path, - ); - - const response = await fetch(url, { - ...params, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${await getAccessToken()}`, - }, - }); - - if (!response.ok) { - throw new Error(response.statusText); - } - return response; -} - -async function request( - path: string, - params: RequestInit, -): Promise { - const response = await get(path, params); - if (response.status !== 204) return response.json(); -} - -async function getAccessToken() { - try { - await keycloak.updateToken(5); - } catch (error) { - keycloak.login(); - } - - return keycloak.token; -} diff --git a/js/apps/account-ui/src/api/methods.ts b/js/apps/account-ui/src/api/methods.ts index 0ba65ade12..2a991b3d43 100644 --- a/js/apps/account-ui/src/api/methods.ts +++ b/js/apps/account-ui/src/api/methods.ts @@ -1,4 +1,4 @@ -import { environment } from "../environment"; +import { KeycloakContext } from "../root/KeycloakContext"; import { joinPath } from "../utils/joinPath"; import { parseResponse } from "./parse-response"; import { @@ -14,6 +14,7 @@ import { import { request } from "./request"; export type CallOptions = { + context: KeycloakContext; signal?: AbortSignal; }; @@ -24,22 +25,27 @@ export type PaginationParams = { export async function getPersonalInfo({ signal, -}: CallOptions = {}): Promise { - const response = await request("/?userProfileMetadata=true", { signal }); + context, +}: CallOptions): Promise { + const response = await request("/?userProfileMetadata=true", context, { + signal, + }); return parseResponse(response); } export async function getSupportedLocales({ signal, -}: CallOptions = {}): Promise { - const response = await request("/supportedLocales", { signal }); + context, +}: CallOptions): Promise { + const response = await request("/supportedLocales", context, { signal }); return parseResponse(response); } export async function savePersonalInfo( + context: KeycloakContext, info: UserRepresentation, ): Promise { - const response = await request("/", { body: info, method: "POST" }); + const response = await request("/", context, { body: info, method: "POST" }); if (!response.ok) { const { errors } = await response.json(); throw errors; @@ -49,10 +55,11 @@ export async function savePersonalInfo( export async function getPermissionRequests( resourceId: string, - { signal }: CallOptions = {}, + { signal, context }: CallOptions, ): Promise { const response = await request( `/resources/${resourceId}/permissions/requests`, + context, { signal }, ); @@ -61,65 +68,89 @@ export async function getPermissionRequests( export async function getDevices({ signal, + context, }: CallOptions): Promise { - const response = await request("/sessions/devices", { signal }); + const response = await request("/sessions/devices", context, { signal }); return parseResponse(response); } -export async function getApplications({ signal }: CallOptions = {}): Promise< - ClientRepresentation[] -> { - const response = await request("/applications", { signal }); +export async function getApplications({ + signal, + context, +}: CallOptions): Promise { + const response = await request("/applications", context, { signal }); return parseResponse(response); } -export async function deleteConsent(id: string) { - return request(`/applications/${id}/consent`, { method: "DELETE" }); +export async function deleteConsent(context: KeycloakContext, id: string) { + return request(`/applications/${id}/consent`, context, { method: "DELETE" }); } -export async function deleteSession(id?: string) { - return request(`/sessions${id ? `/${id}` : ""}`, { +export async function deleteSession(context: KeycloakContext, id?: string) { + return request(`/sessions${id ? `/${id}` : ""}`, context, { method: "DELETE", }); } -export async function getCredentials({ signal }: CallOptions) { - const response = await request("/credentials", { +export async function getCredentials({ signal, context }: CallOptions) { + const response = await request("/credentials", context, { signal, }); return parseResponse(response); } -export async function deleteCredentials(credential: CredentialRepresentation) { - return request("/credentials/" + credential.id, { +export async function deleteCredentials( + context: KeycloakContext, + credential: CredentialRepresentation, +) { + return request("/credentials/" + credential.id, context, { method: "DELETE", }); } -export async function getLinkedAccounts({ signal }: CallOptions) { - const response = await request("/linked-accounts", { signal }); +export async function getLinkedAccounts({ signal, context }: CallOptions) { + const response = await request("/linked-accounts", context, { signal }); return parseResponse(response); } -export async function unLinkAccount(account: LinkedAccountRepresentation) { - const response = await request("/linked-accounts/" + account.providerName, { - method: "DELETE", - }); +export async function unLinkAccount( + context: KeycloakContext, + account: LinkedAccountRepresentation, +) { + const response = await request( + "/linked-accounts/" + account.providerName, + context, + { + method: "DELETE", + }, + ); return parseResponse(response); } -export async function linkAccount(account: LinkedAccountRepresentation) { +export async function linkAccount( + context: KeycloakContext, + account: LinkedAccountRepresentation, +) { const redirectUri = encodeURIComponent( - joinPath(environment.authUrl, "realms", environment.realm, "account"), + joinPath( + context.environment.authUrl, + "realms", + context.environment.realm, + "account", + ), + ); + const response = await request( + "/linked-accounts/" + account.providerName, + context, + { + searchParams: { providerId: account.providerName, redirectUri }, + }, ); - const response = await request("/linked-accounts/" + account.providerName, { - searchParams: { providerId: account.providerName, redirectUri }, - }); return parseResponse<{ accountLinkUri: string }>(response); } -export async function getGroups({ signal }: CallOptions) { - const response = await request("/groups", { +export async function getGroups({ signal, context }: CallOptions) { + const response = await request("/groups", context, { signal, }); return parseResponse(response); diff --git a/js/apps/account-ui/src/api/request.ts b/js/apps/account-ui/src/api/request.ts index dc8de77c60..3c5bc50564 100644 --- a/js/apps/account-ui/src/api/request.ts +++ b/js/apps/account-ui/src/api/request.ts @@ -1,23 +1,21 @@ -import { environment } from "../environment"; -import { keycloak } from "../keycloak"; -import { joinPath } from "../utils/joinPath"; +import { Environment } from "../environment"; +import Keycloak from "keycloak-js"; import { CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON } from "./constants"; +import { joinPath } from "../utils/joinPath"; +import { KeycloakContext } from "../root/KeycloakContext"; export type RequestOptions = { signal?: AbortSignal; + getAccessToken?: () => Promise; method?: "POST" | "PUT" | "DELETE"; searchParams?: Record; body?: unknown; }; -export async function request( - path: string, - { signal, method, searchParams, body }: RequestOptions = {}, +async function _request( + url: URL, + { signal, getAccessToken, method, searchParams, body }: RequestOptions = {}, ): Promise { - const url = new URL( - joinPath(environment.authUrl, "realms", environment.realm, "account", path), - ); - if (searchParams) { Object.entries(searchParams).forEach(([key, value]) => url.searchParams.set(key, value), @@ -30,17 +28,34 @@ export async function request( body: body ? JSON.stringify(body) : undefined, headers: { [CONTENT_TYPE_HEADER]: CONTENT_TYPE_JSON, - authorization: `Bearer ${await getAccessToken()}`, + authorization: `Bearer ${await getAccessToken?.()}`, }, }); } -async function getAccessToken() { - try { - await keycloak.updateToken(5); - } catch (error) { - await keycloak.login(); - } - - return keycloak.token; +export async function request( + path: string, + { environment, keycloak }: KeycloakContext, + opts: RequestOptions = {}, +) { + return _request(url(environment, path), { + ...opts, + getAccessToken: token(keycloak), + }); } + +export const url = (environment: Environment, path: string) => + new URL( + joinPath(environment.authUrl, "realms", environment.realm, "account", path), + ); + +export const token = (keycloak: Keycloak) => + async function getAccessToken() { + try { + await keycloak.updateToken(5); + } catch (error) { + await keycloak.login(); + } + + return keycloak.token; + }; diff --git a/js/apps/account-ui/src/applications/Applications.tsx b/js/apps/account-ui/src/applications/Applications.tsx index ad50e9792e..181b8689c5 100644 --- a/js/apps/account-ui/src/applications/Applications.tsx +++ b/js/apps/account-ui/src/applications/Applications.tsx @@ -27,6 +27,7 @@ import { deleteConsent, getApplications } from "../api/methods"; import { ClientRepresentation } from "../api/representations"; import { Page } from "../components/page/Page"; import { TFuncKey } from "../i18n"; +import { useEnvironment } from "../root/KeycloakContext"; import { formatDate } from "../utils/formatDate"; import { usePromise } from "../utils/usePromise"; @@ -34,8 +35,9 @@ type Application = ClientRepresentation & { open: boolean; }; -const Applications = () => { +export const Applications = () => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const [applications, setApplications] = useState(); @@ -43,7 +45,7 @@ const Applications = () => { const refresh = () => setKey(key + 1); usePromise( - (signal) => getApplications({ signal }), + (signal) => getApplications({ signal, context }), (clients) => setApplications(clients.map((c) => ({ ...c, open: false }))), [key], ); @@ -58,7 +60,7 @@ const Applications = () => { const removeConsent = async (id: string) => { try { - await deleteConsent(id); + await deleteConsent(context, id); refresh(); addAlert(t("removeConsentSuccess")); } catch (error) { diff --git a/js/apps/account-ui/src/content/ContentComponent.tsx b/js/apps/account-ui/src/content/ContentComponent.tsx index c08585fa7e..652a565530 100644 --- a/js/apps/account-ui/src/content/ContentComponent.tsx +++ b/js/apps/account-ui/src/content/ContentComponent.tsx @@ -1,12 +1,12 @@ import { Spinner } from "@patternfly/react-core"; import { Suspense, lazy, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; -import { environment } from "../environment"; +import { useEnvironment } from "../root/KeycloakContext"; +import { MenuItem } from "../root/PageNav"; import { ContentComponentParams } from "../routes"; import { joinPath } from "../utils/joinPath"; import { usePromise } from "../utils/usePromise"; import fetchContentJson from "./fetchContent"; -import { MenuItem } from "../root/PageNav"; function findComponent( content: MenuItem[], @@ -27,11 +27,13 @@ function findComponent( return undefined; } -const ContentComponent = () => { +export const ContentComponent = () => { + const context = useEnvironment(); + const [content, setContent] = useState(); const { componentId } = useParams(); - usePromise((signal) => fetchContentJson({ signal }), setContent); + usePromise((signal) => fetchContentJson({ signal, context }), setContent); const modulePath = useMemo( () => findComponent(content || [], componentId!), [content, componentId], @@ -45,6 +47,8 @@ type ComponentProps = { }; const Component = ({ modulePath }: ComponentProps) => { + const { environment } = useEnvironment(); + const Element = lazy( () => import(joinPath(environment.resourceUrl, modulePath)), ); diff --git a/js/apps/account-ui/src/content/fetchContent.ts b/js/apps/account-ui/src/content/fetchContent.ts index 94c2d98447..5ffae28190 100644 --- a/js/apps/account-ui/src/content/fetchContent.ts +++ b/js/apps/account-ui/src/content/fetchContent.ts @@ -1,13 +1,12 @@ import { CallOptions } from "../api/methods"; -import { environment } from "../environment"; import { MenuItem } from "../root/PageNav"; import { joinPath } from "../utils/joinPath"; export default async function fetchContentJson( - opts: CallOptions = {}, + opts: CallOptions, ): Promise { const response = await fetch( - joinPath(environment.resourceUrl, "/content.json"), + joinPath(opts.context.environment.resourceUrl, "/content.json"), opts, ); return await response.json(); diff --git a/js/apps/account-ui/src/groups/Groups.tsx b/js/apps/account-ui/src/groups/Groups.tsx index 37a1bbf8de..151c2bdbfe 100644 --- a/js/apps/account-ui/src/groups/Groups.tsx +++ b/js/apps/account-ui/src/groups/Groups.tsx @@ -11,16 +11,18 @@ import { useTranslation } from "react-i18next"; import { getGroups } from "../api/methods"; import { Group } from "../api/representations"; import { Page } from "../components/page/Page"; +import { useEnvironment } from "../root/KeycloakContext"; import { usePromise } from "../utils/usePromise"; -const Groups = () => { +export const Groups = () => { const { t } = useTranslation(); + const context = useEnvironment(); const [groups, setGroups] = useState([]); const [directMembership, setDirectMembership] = useState(false); usePromise( - (signal) => getGroups({ signal }), + (signal) => getGroups({ signal, context }), (groups) => { if (directMembership) { groups.forEach((el) => diff --git a/js/apps/account-ui/src/index.ts b/js/apps/account-ui/src/index.ts new file mode 100644 index 0000000000..ec5fd61458 --- /dev/null +++ b/js/apps/account-ui/src/index.ts @@ -0,0 +1,42 @@ +export { PersonalInfo } from "./personal-info/PersonalInfo"; +export { ErrorPage } from "./root/ErrorPage"; +export { Header } from "./root/Header"; +export { KeycloakProvider, useEnvironment } from "./root/KeycloakContext"; +export { PageNav } from "./root/PageNav"; +export { DeviceActivity } from "./account-security/DeviceActivity"; +export { LinkedAccounts } from "./account-security/LinkedAccounts"; +export { SigningIn } from "./account-security/SigningIn"; +export type { + AccountLinkUriRepresentation, + Client, + ClientRepresentation, + ConsentRepresentation, + ConsentScopeRepresentation, + CredentialContainer, + CredentialMetadataRepresentation, + CredentialRepresentation, + CredentialTypeMetadata, + DeviceRepresentation, + Group, + LinkedAccountRepresentation, + Permission, + Permissions, + Resource, + Scope, + SessionRepresentation, + UserProfileAttributeMetadata, + UserProfileMetadata, + UserRepresentation, +} from "./api/representations"; +export { Applications } from "./applications/Applications"; +export { EmptyRow } from "./components/datalist/EmptyRow"; +export { Page } from "./components/page/Page"; +export { ContentComponent } from "./content/ContentComponent"; +export { Groups } from "./groups/Groups"; +export { EditTheResource } from "./resources/EditTheResource"; +export { PermissionRequest } from "./resources/PermissionRequest"; +export { Resources } from "./resources/Resources"; +export { ResourcesTab } from "./resources/ResourcesTab"; +export { ResourceToolbar } from "./resources/ResourceToolbar"; +export { SharedWith } from "./resources/SharedWith"; +export { ShareTheResource } from "./resources/ShareTheResource"; diff --git a/js/apps/account-ui/src/keycloak.ts b/js/apps/account-ui/src/keycloak.ts deleted file mode 100644 index ecf36685e5..0000000000 --- a/js/apps/account-ui/src/keycloak.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Keycloak from "keycloak-js"; -import { environment } from "./environment"; - -export const keycloak = new Keycloak({ - url: environment.authUrl, - realm: environment.realm, - clientId: environment.clientId, -}); diff --git a/js/apps/account-ui/src/main.tsx b/js/apps/account-ui/src/main.tsx index 2e0dec914c..e112e0d720 100644 --- a/js/apps/account-ui/src/main.tsx +++ b/js/apps/account-ui/src/main.tsx @@ -6,16 +6,10 @@ import { createRoot } from "react-dom/client"; import { createHashRouter, RouterProvider } from "react-router-dom"; import { i18n } from "./i18n"; -import { keycloak } from "./keycloak"; import { routes } from "./routes"; // Initialize required components before rendering app. -await Promise.all([ - keycloak.init({ - onLoad: "check-sso", - }), - i18n.init(), -]); +await i18n.init(); const router = createHashRouter(routes); const container = document.getElementById("app"); diff --git a/js/apps/account-ui/src/personal-info/PersonalInfo.tsx b/js/apps/account-ui/src/personal-info/PersonalInfo.tsx index eec3b80b55..6815406d49 100644 --- a/js/apps/account-ui/src/personal-info/PersonalInfo.tsx +++ b/js/apps/account-ui/src/personal-info/PersonalInfo.tsx @@ -28,12 +28,13 @@ import { UserRepresentation, } from "../api/representations"; import { Page } from "../components/page/Page"; -import { environment } from "../environment"; import { TFuncKey, i18n } from "../i18n"; +import { useEnvironment } from "../root/KeycloakContext"; import { usePromise } from "../utils/usePromise"; -const PersonalInfo = () => { +export const PersonalInfo = () => { const { t } = useTranslation(); + const context = useEnvironment(); const keycloak = useKeycloak(); const [userProfileMetadata, setUserProfileMetadata] = useState(); @@ -45,8 +46,8 @@ const PersonalInfo = () => { usePromise( (signal) => Promise.all([ - getPersonalInfo({ signal }), - getSupportedLocales({ signal }), + getPersonalInfo({ signal, context }), + getSupportedLocales({ signal, context }), ]), ([personalInfo, supportedLocales]) => { setUserProfileMetadata(personalInfo.userProfileMetadata); @@ -57,7 +58,7 @@ const PersonalInfo = () => { const onSubmit = async (user: UserRepresentation) => { try { - await savePersonalInfo(user); + await savePersonalInfo(context, user); const locale = user.attributes?.["locale"]?.toString(); i18n.changeLanguage(locale, (error) => { if (error) { @@ -87,7 +88,7 @@ const PersonalInfo = () => { updateEmailActionEnabled, isRegistrationEmailAsUsername, isEditUserNameAllowed, - } = environment.features; + } = context.environment.features; return (
@@ -136,7 +137,7 @@ const PersonalInfo = () => { {t("cancel")} - {environment.features.deleteAccountAllowed && ( + {context.environment.features.deleteAccountAllowed && ( { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const form = useForm(); @@ -39,7 +41,7 @@ export const EditTheResource = ({ try { await Promise.all( permissions.map((permission) => - updatePermissions(resource._id, [permission]), + updatePermissions(context, resource._id, [permission]), ), ); addAlert(t("updateSuccess")); diff --git a/js/apps/account-ui/src/resources/PermissionRequest.tsx b/js/apps/account-ui/src/resources/PermissionRequest.tsx index 2119f822f0..a76937f915 100644 --- a/js/apps/account-ui/src/resources/PermissionRequest.tsx +++ b/js/apps/account-ui/src/resources/PermissionRequest.tsx @@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next"; import { useAlerts } from "ui-shared"; import { fetchPermission, updateRequest } from "../api"; import { Permission, Resource } from "../api/representations"; +import { useEnvironment } from "../root/KeycloakContext"; type PermissionRequestProps = { resource: Resource; @@ -32,6 +33,7 @@ export const PermissionRequest = ({ refresh, }: PermissionRequestProps) => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const [open, setOpen] = useState(false); @@ -43,12 +45,13 @@ export const PermissionRequest = ({ approve: boolean = false, ) => { try { - const permissions = await fetchPermission({}, resource._id); + const permissions = await fetchPermission({ context }, resource._id); const { scopes, username } = permissions.find( (p) => p.username === shareRequest.username, )!; await updateRequest( + context, resource._id, username, approve diff --git a/js/apps/account-ui/src/resources/Resources.tsx b/js/apps/account-ui/src/resources/Resources.tsx index ecac9ace83..9b87519c8b 100644 --- a/js/apps/account-ui/src/resources/Resources.tsx +++ b/js/apps/account-ui/src/resources/Resources.tsx @@ -5,7 +5,7 @@ import { Tab, Tabs, TabTitleText } from "@patternfly/react-core"; import { ResourcesTab } from "./ResourcesTab"; import { Page } from "../components/page/Page"; -const Resources = () => { +export const Resources = () => { const { t } = useTranslation(); const [activeTabKey, setActiveTabKey] = useState(0); diff --git a/js/apps/account-ui/src/resources/ResourcesTab.tsx b/js/apps/account-ui/src/resources/ResourcesTab.tsx index e8ceb935b6..f0251586f9 100644 --- a/js/apps/account-ui/src/resources/ResourcesTab.tsx +++ b/js/apps/account-ui/src/resources/ResourcesTab.tsx @@ -31,17 +31,18 @@ import { import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { ContinueCancelModal, useAlerts } from "ui-shared"; import { fetchPermission, fetchResources, updatePermissions } from "../api"; import { getPermissionRequests } from "../api/methods"; import { Links } from "../api/parse-links"; import { Permission, Resource } from "../api/representations"; -import { ContinueCancelModal, useAlerts } from "ui-shared"; +import { useEnvironment } from "../root/KeycloakContext"; 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"; +import { SharedWith } from "./SharedWith"; type PermissionDetail = { contextOpen?: boolean; @@ -57,6 +58,7 @@ type ResourcesTabProps = { export const ResourcesTab = ({ isShared = false }: ResourcesTabProps) => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const [params, setParams] = useState>({ @@ -73,13 +75,18 @@ export const ResourcesTab = ({ isShared = false }: ResourcesTabProps) => { usePromise( async (signal) => { - const result = await fetchResources({ signal }, params, isShared); + const result = await fetchResources( + { signal, context }, + params, + isShared, + ); if (!isShared) await Promise.all( result.data.map( async (r) => (r.shareRequests = await getPermissionRequests(r._id, { signal, + context, })), ), ); @@ -99,7 +106,7 @@ export const ResourcesTab = ({ isShared = false }: ResourcesTabProps) => { const fetchPermissions = async (id: string) => { let permissions = details[id]?.permissions || []; if (!details[id]) { - permissions = await fetchPermission({}, id); + permissions = await fetchPermission({ context }, id); } return permissions; }; @@ -113,7 +120,7 @@ export const ResourcesTab = ({ isShared = false }: ResourcesTabProps) => { scopes: [], }) as Permission, )!; - await updatePermissions(resource._id, permissions); + await updatePermissions(context, resource._id, permissions); setDetails({}); addAlert(t("unShareSuccess")); } catch (error) { diff --git a/js/apps/account-ui/src/resources/ShareTheResource.tsx b/js/apps/account-ui/src/resources/ShareTheResource.tsx index 6c46dfdf54..6cd67a963c 100644 --- a/js/apps/account-ui/src/resources/ShareTheResource.tsx +++ b/js/apps/account-ui/src/resources/ShareTheResource.tsx @@ -17,9 +17,10 @@ import { } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { KeycloakTextInput, SelectControl, useAlerts } from "ui-shared"; import { updateRequest } from "../api"; import { Permission, Resource } from "../api/representations"; -import { useAlerts, SelectControl, KeycloakTextInput } from "ui-shared"; +import { useEnvironment } from "../root/KeycloakContext"; import { SharedWith } from "./SharedWith"; type ShareTheResourceProps = { @@ -41,6 +42,7 @@ export const ShareTheResource = ({ onClose, }: ShareTheResourceProps) => { const { t } = useTranslation(); + const context = useEnvironment(); const { addAlert, addError } = useAlerts(); const form = useForm(); const { @@ -79,7 +81,7 @@ export const ShareTheResource = ({ usernames .filter(({ value }) => value !== "") .map(({ value: username }) => - updateRequest(resource._id, username, permissions), + updateRequest(context, resource._id, username, permissions), ), ); addAlert(t("shareSuccess")); diff --git a/js/apps/account-ui/src/root/Header.tsx b/js/apps/account-ui/src/root/Header.tsx new file mode 100644 index 0000000000..f85689e735 --- /dev/null +++ b/js/apps/account-ui/src/root/Header.tsx @@ -0,0 +1,74 @@ +import { + KeycloakMasthead, + KeycloakProvider, + Translations, + TranslationsProvider, +} from "keycloak-masthead"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useHref } from "react-router-dom"; +import { useEnvironment } from "./KeycloakContext"; +import { joinPath } from "../utils/joinPath"; +import { ExternalLinkSquareAltIcon } from "@patternfly/react-icons"; +import { Button } from "@patternfly/react-core"; + +import style from "./header.module.css"; + +const ReferrerLink = () => { + const { t } = useTranslation(); + const searchParams = new URLSearchParams(location.search); + + return searchParams.has("referrer_uri") ? ( + + ) : null; +}; + +export const Header = () => { + const { environment, keycloak } = useEnvironment(); + const { t } = useTranslation(); + + const brandImage = environment.logo || "logo.svg"; + const logoUrl = environment.logoUrl ? environment.logoUrl : "/"; + const internalLogoHref = useHref(logoUrl); + + // User can indicate that he wants an internal URL by starting it with "/" + const indexHref = logoUrl.startsWith("/") ? internalLogoHref : logoUrl; + const translations = useMemo( + () => ({ + avatar: t("avatar"), + fullName: t("fullName"), + manageAccount: t("manageAccount"), + signOut: t("signOut"), + unknownUser: t("unknownUser"), + }), + [t], + ); + + return ( + + + ]} + /> + + + ); +}; diff --git a/js/apps/account-ui/src/root/KeycloakContext.tsx b/js/apps/account-ui/src/root/KeycloakContext.tsx new file mode 100644 index 0000000000..5047235a55 --- /dev/null +++ b/js/apps/account-ui/src/root/KeycloakContext.tsx @@ -0,0 +1,77 @@ +import { Spinner } from "@patternfly/react-core"; +import Keycloak from "keycloak-js"; +import { + PropsWithChildren, + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AlertProvider, Help } from "ui-shared"; +import { Environment } from "../environment"; + +export type KeycloakContext = KeycloakContextProps & { + keycloak: Keycloak; +}; + +const KeycloakEnvContext = createContext( + undefined, +); + +export const useEnvironment = () => { + const context = useContext(KeycloakEnvContext); + if (!context) + throw Error( + "no environment provider in the hierarchy make sure to add the provider", + ); + return context; +}; + +type KeycloakContextProps = { + environment: Environment; +}; + +export const KeycloakProvider = ({ + environment, + children, +}: PropsWithChildren) => { + const calledOnce = useRef(false); + const [init, setInit] = useState(false); + const keycloak = useMemo( + () => + new Keycloak({ + url: environment.authUrl, + realm: environment.realm, + clientId: environment.clientId, + }), + [environment], + ); + + useEffect(() => { + // only needed in dev mode + if (calledOnce.current) { + return; + } + const init = () => { + return keycloak.init({ + onLoad: "check-sso", + pkceMethod: "S256", + responseMode: "query", + }); + }; + init().then(() => setInit(true)); + calledOnce.current = true; + }, [keycloak]); + + if (!init) return ; + + return ( + + + {children} + + + ); +}; diff --git a/js/apps/account-ui/src/root/PageNav.tsx b/js/apps/account-ui/src/root/PageNav.tsx index 6ee3696331..1fbbf6b9f9 100644 --- a/js/apps/account-ui/src/root/PageNav.tsx +++ b/js/apps/account-ui/src/root/PageNav.tsx @@ -22,29 +22,31 @@ import { useLocation, } from "react-router-dom"; import fetchContentJson from "../content/fetchContent"; +import type { Feature } from "../environment"; import { TFuncKey } from "../i18n"; import { usePromise } from "../utils/usePromise"; -import { Feature, environment } from "../environment"; +import { useEnvironment } from "./KeycloakContext"; type RootMenuItem = { label: TFuncKey; path: string; - isHidden?: keyof Feature; + isVisible?: keyof Feature; modulePath?: string; }; type MenuItemWithChildren = { label: TFuncKey; children: MenuItem[]; - isHidden?: keyof Feature; + isVisible?: keyof Feature; }; export type MenuItem = RootMenuItem | MenuItemWithChildren; export const PageNav = () => { const [menuItems, setMenuItems] = useState(); + const context = useEnvironment(); - usePromise((signal) => fetchContentJson({ signal }), setMenuItems); + usePromise((signal) => fetchContentJson({ signal, context }), setMenuItems); return ( { }> {menuItems ?.filter((menuItem) => - menuItem.isHidden - ? environment.features[menuItem.isHidden] + menuItem.isVisible + ? context.environment.features[menuItem.isVisible] : true, ) .map((menuItem) => ( @@ -77,6 +79,9 @@ type NavMenuItemProps = { function NavMenuItem({ menuItem }: NavMenuItemProps) { const { t } = useTranslation(); + const { + environment: { features }, + } = useEnvironment(); const { pathname } = useLocation(); const isActive = useMemo( () => matchMenuItem(pathname, menuItem), @@ -99,7 +104,9 @@ function NavMenuItem({ menuItem }: NavMenuItemProps) { isExpanded={isActive} > {menuItem.children - .filter((menuItem) => !menuItem.isHidden) + .filter((menuItem) => + menuItem.isVisible ? features[menuItem.isVisible] : true, + ) .map((child) => ( ))} diff --git a/js/apps/account-ui/src/root/Root.tsx b/js/apps/account-ui/src/root/Root.tsx index d37e482ac2..2a298538b0 100644 --- a/js/apps/account-ui/src/root/Root.tsx +++ b/js/apps/account-ui/src/root/Root.tsx @@ -1,89 +1,18 @@ -import { Button, Page, Spinner } from "@patternfly/react-core"; -import { ExternalLinkSquareAltIcon } from "@patternfly/react-icons"; -import { - KeycloakMasthead, - KeycloakProvider, - Translations, - TranslationsProvider, -} from "keycloak-masthead"; -import { Suspense, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Outlet, useHref } from "react-router-dom"; -import { AlertProvider, Help } from "ui-shared"; +import { Page, Spinner } from "@patternfly/react-core"; +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; import { environment } from "../environment"; -import { keycloak } from "../keycloak"; -import { joinPath } from "../utils/joinPath"; +import { Header } from "./Header"; +import { KeycloakProvider } from "./KeycloakContext"; import { PageNav } from "./PageNav"; -import style from "./Root.module.css"; - -const ReferrerLink = () => { - const { t } = useTranslation(); - const searchParams = new URLSearchParams(location.search); - - return searchParams.has("referrer_uri") ? ( - - ) : null; -}; - export const Root = () => { - const { t } = useTranslation(); - const brandImage = environment.logo || "logo.svg"; - const logoUrl = environment.logoUrl ? environment.logoUrl : "/"; - const internalLogoHref = useHref(logoUrl); - - // User can indicate that he wants an internal URL by starting it with "/" - const indexHref = logoUrl.startsWith("/") ? internalLogoHref : logoUrl; - - const translations = useMemo( - () => ({ - avatar: t("avatar"), - fullName: t("fullName"), - manageAccount: t("manageAccount"), - signOut: t("signOut"), - unknownUser: t("unknownUser"), - }), - [t], - ); - return ( - - - ]} - /> - - } - sidebar={} - isManagedSidebar - > - - - }> - - - - + + } sidebar={} isManagedSidebar> + }> + + ); diff --git a/js/apps/account-ui/src/root/Root.module.css b/js/apps/account-ui/src/root/header.module.css similarity index 100% rename from js/apps/account-ui/src/root/Root.module.css rename to js/apps/account-ui/src/root/header.module.css diff --git a/js/apps/account-ui/test/account-security.spec.ts b/js/apps/account-ui/test/account-security.spec.ts index c0568dd1ac..4f37bd6cbe 100644 --- a/js/apps/account-ui/test/account-security.spec.ts +++ b/js/apps/account-ui/test/account-security.spec.ts @@ -3,11 +3,7 @@ import { login } from "./login"; test("Check page heading", async ({ page }) => { await login(page, "alice", "alice", "user-profile"); - await page - .getByRole("button", { - name: "Account security", - }) - .click(); + await page.getByTestId("accountSecurity").click(); const linkedAccountsNavItem = page.getByTestId( "account-security/linked-accounts", diff --git a/js/apps/account-ui/vite.config.ts b/js/apps/account-ui/vite.config.ts index af1e4ccb2d..9f56172618 100644 --- a/js/apps/account-ui/vite.config.ts +++ b/js/apps/account-ui/vite.config.ts @@ -1,21 +1,36 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; +import path from "path"; +import { defineConfig, loadEnv } from "vite"; import { checker } from "vite-plugin-checker"; // https://vitejs.dev/config/ -export default defineConfig({ - base: "", - server: { - port: 8080, - }, - build: { - sourcemap: true, - target: "esnext", - modulePreload: false, - cssMinify: "lightningcss", - rollupOptions: { - external: ["react", "react/jsx-runtime", "react-dom"], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const external = ["react", "react/jsx-runtime", "react-dom"]; + if (env.LIB) external.push("react-router-dom"); + const lib = env.LIB + ? { + lib: { + entry: path.resolve(__dirname, "src/index.ts"), + formats: ["es"], + }, + } + : undefined; + return { + base: "", + server: { + port: 8080, }, - }, - plugins: [react(), checker({ typescript: true })], + build: { + ...lib, + sourcemap: true, + target: "esnext", + modulePreload: false, + cssMinify: "lightningcss", + rollupOptions: { + external: external, + }, + }, + plugins: [react(), checker({ typescript: true })], + }; });