account library (#26373)

* initial version

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* better init

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added more components

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* moved to public

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* use environment var to enter library mode

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* added export field

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

---------

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-02-01 16:46:11 +01:00 committed by GitHub
parent 4b151e040f
commit 5d39625d82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 458 additions and 279 deletions

View file

@ -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",

View file

@ -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());

View file

@ -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<DeviceRepresentation[]>();
@ -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 }),
);

View file

@ -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<LinkedAccountRepresentation[]>([]);
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),

View file

@ -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<CredentialContainer[]>();
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),

View file

@ -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<string, string>,
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<Permission[]> => {
const response = await request<Permission[]>(
const response = await request(
`/resources/${resourceId}/permissions`,
params,
context,
{ signal },
);
return checkResponse(response);
return parseResponse<Permission[]>(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<T>(response: T) {
if (!response) throw new Error("Could not fetch");
return response;
}
async function get(path: string, params: RequestInit): Promise<Response> {
const url = joinPath(
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<T>(
path: string,
params: RequestInit,
): Promise<T | undefined> {
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;
}

View file

@ -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<UserRepresentation> {
const response = await request("/?userProfileMetadata=true", { signal });
context,
}: CallOptions): Promise<UserRepresentation> {
const response = await request("/?userProfileMetadata=true", context, {
signal,
});
return parseResponse<UserRepresentation>(response);
}
export async function getSupportedLocales({
signal,
}: CallOptions = {}): Promise<string[]> {
const response = await request("/supportedLocales", { signal });
context,
}: CallOptions): Promise<string[]> {
const response = await request("/supportedLocales", context, { signal });
return parseResponse<string[]>(response);
}
export async function savePersonalInfo(
context: KeycloakContext,
info: UserRepresentation,
): Promise<void> {
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<Permission[]> {
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<DeviceRepresentation[]> {
const response = await request("/sessions/devices", { signal });
const response = await request("/sessions/devices", context, { signal });
return parseResponse<DeviceRepresentation[]>(response);
}
export async function getApplications({ signal }: CallOptions = {}): Promise<
ClientRepresentation[]
> {
const response = await request("/applications", { signal });
export async function getApplications({
signal,
context,
}: CallOptions): Promise<ClientRepresentation[]> {
const response = await request("/applications", context, { signal });
return parseResponse<ClientRepresentation[]>(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<CredentialContainer[]>(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<LinkedAccountRepresentation[]>(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<Group[]>(response);

View file

@ -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<string | undefined>;
method?: "POST" | "PUT" | "DELETE";
searchParams?: Record<string, string>;
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<Response> {
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;
};

View file

@ -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<Application[]>();
@ -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) {

View file

@ -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<MenuItem[]>();
const { componentId } = useParams<ContentComponentParams>();
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)),
);

View file

@ -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<MenuItem[]> {
const response = await fetch(
joinPath(environment.resourceUrl, "/content.json"),
joinPath(opts.context.environment.resourceUrl, "/content.json"),
opts,
);
return await response.json();

View file

@ -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<Group[]>([]);
const [directMembership, setDirectMembership] = useState(false);
usePromise(
(signal) => getGroups({ signal }),
(signal) => getGroups({ signal, context }),
(groups) => {
if (directMembership) {
groups.forEach((el) =>

View file

@ -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";

View file

@ -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,
});

View file

@ -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");

View file

@ -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<UserProfileMetadata>();
@ -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 (
<Page title={t("personalInfo")} description={t("personalInfoDescription")}>
<Form isHorizontal onSubmit={handleSubmit(onSubmit)}>
@ -136,7 +137,7 @@ const PersonalInfo = () => {
{t("cancel")}
</Button>
</ActionGroup>
{environment.features.deleteAccountAllowed && (
{context.environment.features.deleteAccountAllowed && (
<ExpandableSection
data-testid="delete-account"
toggleText={t("deleteAccount")}

View file

@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { KeycloakTextInput, SelectControl, useAlerts } from "ui-shared";
import { updatePermissions } from "../api";
import type { Permission, Resource } from "../api/representations";
import { useEnvironment } from "../root/KeycloakContext";
type EditTheResourceProps = {
resource: Resource;
@ -23,6 +24,7 @@ export const EditTheResource = ({
onClose,
}: EditTheResourceProps) => {
const { t } = useTranslation();
const context = useEnvironment();
const { addAlert, addError } = useAlerts();
const form = useForm<FormValues>();
@ -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"));

View file

@ -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

View file

@ -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);

View file

@ -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<Record<string, string>>({
@ -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) {

View file

@ -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<FormValues>();
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"));

View file

@ -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") ? (
<Button
data-testid="referrer-link"
component="a"
href={searchParams.get("referrer_uri")!.replace("_hash_", "#")}
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
isInline
>
{t("backTo", { app: searchParams.get("referrer") })}
</Button>
) : 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<Translations>(
() => ({
avatar: t("avatar"),
fullName: t("fullName"),
manageAccount: t("manageAccount"),
signOut: t("signOut"),
unknownUser: t("unknownUser"),
}),
[t],
);
return (
<TranslationsProvider translations={translations}>
<KeycloakProvider keycloak={keycloak}>
<KeycloakMasthead
features={{ hasManageAccount: false }}
showNavToggle
brand={{
href: indexHref,
src: joinPath(environment.resourceUrl, brandImage),
alt: t("logo"),
className: style.brand,
}}
toolbarItems={[<ReferrerLink key="link" />]}
/>
</KeycloakProvider>
</TranslationsProvider>
);
};

View file

@ -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<KeycloakContext | undefined>(
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<KeycloakContextProps>) => {
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 <Spinner />;
return (
<KeycloakEnvContext.Provider value={{ environment, keycloak }}>
<AlertProvider>
<Help>{children}</Help>
</AlertProvider>
</KeycloakEnvContext.Provider>
);
};

View file

@ -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<MenuItem[]>();
const context = useEnvironment();
usePromise((signal) => fetchContentJson({ signal }), setMenuItems);
usePromise((signal) => fetchContentJson({ signal, context }), setMenuItems);
return (
<PageSidebar
nav={
@ -53,8 +55,8 @@ export const PageNav = () => {
<Suspense fallback={<Spinner />}>
{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) => (
<NavMenuItem key={child.label as string} menuItem={child} />
))}

View file

@ -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") ? (
<Button
data-testid="referrer-link"
component="a"
href={searchParams.get("referrer_uri")!.replace("_hash_", "#")}
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
isInline
>
{t("backTo", { app: searchParams.get("referrer") })}
</Button>
) : 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<Translations>(
() => ({
avatar: t("avatar"),
fullName: t("fullName"),
manageAccount: t("manageAccount"),
signOut: t("signOut"),
unknownUser: t("unknownUser"),
}),
[t],
);
return (
<KeycloakProvider keycloak={keycloak}>
<Page
header={
<TranslationsProvider translations={translations}>
<KeycloakMasthead
features={{ hasManageAccount: false }}
showNavToggle
brand={{
href: indexHref,
src: joinPath(environment.resourceUrl, brandImage),
alt: t("logo"),
className: style.brand,
}}
toolbarItems={[<ReferrerLink key="link" />]}
/>
</TranslationsProvider>
}
sidebar={<PageNav />}
isManagedSidebar
>
<AlertProvider>
<Help>
<Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</Help>
</AlertProvider>
<KeycloakProvider environment={environment}>
<Page header={<Header />} sidebar={<PageNav />} isManagedSidebar>
<Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</Page>
</KeycloakProvider>
);

View file

@ -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",

View file

@ -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 })],
};
});