Add account-ui device activity page (#4256)

This commit is contained in:
Erik Jan de Wit 2023-02-03 16:08:40 +01:00 committed by GitHub
parent 68fbc8db61
commit ff1d7fcfc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 301 additions and 4 deletions

View file

@ -11,13 +11,19 @@
"avatar": "Avatar",
"cancel": "Cancel",
"client": "Client",
"clients": "Clients",
"close": "Close",
"currentSession": "Current session",
"description": "Description",
"device-activity": "Device activity",
"deviceActivity": "Device activity",
"doDeny": "Deny",
"done": "Done",
"doSignOut": "Sign out",
"edit": "Edit",
"editTheResource": "Share the resource - {{0}}",
"errorSignOutMessage": "Could not be signed out: {{error}}",
"expires": "Expires",
"filterByName": "Filter By Name ...",
"firstName": "First name",
"fullName": "{{givenName}} {{familyName}}",
@ -25,6 +31,8 @@
"infoMessage": "By clicking Remove Access, you will remove granted permissions of this application. This application will no longer use your information.",
"internalApp": "Internal",
"inUse": "In use",
"ipAddress": "IP address",
"lastAccessedOn": "Last accessed",
"lastName": "Last name",
"linkedAccounts": "Linked accounts",
"logo": "Logo",
@ -39,6 +47,7 @@
"personalInfo": "Personal info",
"personalInfoDescription": "Manage your basic information",
"privacyPolicy": "Privacy policy",
"refreshPage": "Refresh the page",
"removeButton": "Remove access",
"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.",
"removeModalTitle": "Remove access",
@ -56,14 +65,21 @@
"shareTheResource": "Share the resource - {{0}}",
"shareUser": "Add users to share your resource with",
"shareWith": "Share with ",
"signedInDevices": "Signed in devices",
"signedInDevicesExplanation": "Sign out of any unfamiliar devices.",
"signedOutSession": "Signed out {{0}}/{{1}}",
"signingIn": "Signing in",
"signOut": "Sign out",
"signOutAllDevices": "Sign out all devices",
"signOutAllDevicesWarning": "This action will sign out all the devices that have signed in to your account, including the current device you are using.",
"somethingWentWrong": "Something went wrong",
"somethingWentWrongDescription": "Sorry, an unexpected error has occurred.",
"started": "Started",
"status": "Status",
"termsOfService": "Terms of service",
"thirdPartyApp": "Third-party",
"tryAgain": "Try again",
"unknownOperatingSystem": "Unknown operating system",
"unknownUser": "Anonymous",
"unShare": "Unshare all",
"user": "User",

View file

@ -1,7 +1,245 @@
import { PageSection } from "@patternfly/react-core";
import {
Button,
DataList,
DataListContent,
DataListItem,
DataListItemRow,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
Grid,
GridItem,
Label,
Spinner,
Split,
SplitItem,
Title,
Tooltip,
} from "@patternfly/react-core";
import {
SyncAltIcon,
MobileAltIcon,
DesktopIcon,
} from "@patternfly/react-icons";
import { TFuncKey } from "i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { deleteSession, getDevices } from "../api/methods";
import {
DeviceRepresentation,
SessionRepresentation,
ClientRepresentation,
} from "../api/representations";
import { useAlerts } from "../components/alerts/Alerts";
import { ContinueCancelModal } from "../components/continue-cancel/ContinueCancelModel";
import useFormatter from "../components/formatter/format-date";
import { Page } from "../components/page/Page";
import { keycloak } from "../keycloak";
import { usePromise } from "../utils/usePromise";
const DeviceActivity = () => (
<PageSection>This is the device activity page.</PageSection>
);
const DeviceActivity = () => {
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const { formatTime } = useFormatter();
const [devices, setDevices] = useState<DeviceRepresentation[]>();
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
const moveCurrentToTop = (devices: DeviceRepresentation[]) => {
let currentDevice = devices[0];
const index = devices.findIndex((d) => d.current);
currentDevice = devices.splice(index, 1)[0];
devices.unshift(currentDevice);
const sessionIndex = currentDevice.sessions.findIndex((s) => s.current);
const currentSession = currentDevice.sessions.splice(sessionIndex, 1)[0];
currentDevice.sessions.unshift(currentSession);
setDevices(devices);
};
usePromise((signal) => getDevices({ signal }), moveCurrentToTop, [key]);
const signOutAll = async () => {
await deleteSession();
keycloak.logout();
};
const signOutSession = async (
session: SessionRepresentation,
device: DeviceRepresentation
) => {
try {
await deleteSession(session.id);
addAlert(t("signedOutSession", [session.browser, device.os]));
refresh();
} catch (error) {
addError("errorSignOutMessage", error);
}
};
const makeClientsString = (clients: ClientRepresentation[]): string => {
let clientsString = "";
clients.forEach((client, index) => {
let clientName: string;
if (client.clientName !== "") {
clientName = t(client.clientName as TFuncKey);
} else {
clientName = client.clientId;
}
clientsString += clientName;
if (clients.length > index + 1) clientsString += ", ";
});
return clientsString;
};
if (!devices) {
return <Spinner />;
}
return (
<Page
title={t("device-activity")}
description={t("signedInDevicesExplanation")}
>
<Split hasGutter className="pf-u-mb-lg">
<SplitItem isFilled>
<Title headingLevel="h2" size="xl">
{t("signedInDevices")}
</Title>
</SplitItem>
<SplitItem>
<Tooltip content={t("refreshPage")}>
<Button
aria-describedby="refresh page"
id="refresh-page"
variant="link"
onClick={() => refresh()}
icon={<SyncAltIcon />}
>
Refresh
</Button>
</Tooltip>
{(devices.length > 1 || devices[0].sessions.length > 1) && (
<ContinueCancelModal
buttonTitle="signOutAllDevices"
modalTitle="signOutAllDevices"
modalMessage="signOutAllDevicesWarning"
onContinue={() => signOutAll()}
/>
)}
</SplitItem>
</Split>
<DataList
className="signed-in-device-list"
aria-label={t("signedInDevices")}
>
<DataListItem aria-labelledby="sessions">
{devices.map((device) =>
device.sessions.map((session) => (
<DataListItemRow key={device.id}>
<DataListContent
aria-label="device-sessions-content"
className="pf-u-flex-grow-1"
>
<Grid hasGutter>
<GridItem span={1} rowSpan={2}>
{device.mobile ? <MobileAltIcon /> : <DesktopIcon />}
</GridItem>
<GridItem sm={8} md={9} span={10}>
<span className="pf-u-mr-md session-title">
{device.os.toLowerCase().includes("unknown")
? t("unknownOperatingSystem")
: device.os}{" "}
{!device.osVersion.toLowerCase().includes("unknown") &&
device.osVersion}{" "}
/ {session.browser}
</span>
{session.current && (
<Label color="green">{t("currentSession")}</Label>
)}
</GridItem>
<GridItem
className="pf-u-text-align-right"
sm={3}
md={2}
span={1}
>
{!session.current && (
<ContinueCancelModal
buttonTitle="doSignOut"
modalTitle="doSignOut"
buttonVariant="secondary"
modalMessage="signOutWarning"
onContinue={() => signOutSession(session, device)}
/>
)}
</GridItem>
<GridItem span={11}>
<DescriptionList
className="signed-in-device-grid"
columnModifier={{ sm: "2Col", lg: "3Col" }}
cols={5}
rows={1}
>
<DescriptionListGroup>
<DescriptionListTerm>
{t("ipAddress")}
</DescriptionListTerm>
<DescriptionListDescription>
{session.ipAddress}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
{t("lastAccessedOn")}
</DescriptionListTerm>
<DescriptionListDescription>
{formatTime(session.lastAccess)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
{t("clients")}
</DescriptionListTerm>
<DescriptionListDescription>
{makeClientsString(session.clients)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
{t("started")}
</DescriptionListTerm>
<DescriptionListDescription>
{formatTime(session.started)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
{t("expires")}
</DescriptionListTerm>
<DescriptionListDescription>
{formatTime(session.expires)}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</GridItem>
</Grid>
</DataListContent>
</DataListItemRow>
))
)}
</DataListItem>
</DataList>
</Page>
);
};
export default DeviceActivity;

View file

@ -1,6 +1,7 @@
import { parseResponse } from "./parse-response";
import {
ClientRepresentation,
DeviceRepresentation,
Permission,
UserRepresentation,
} from "./representations";
@ -34,6 +35,13 @@ export async function getPermissionRequests(
return parseResponse<Permission[]>(response);
}
export async function getDevices({
signal,
}: CallOptions): Promise<DeviceRepresentation[]> {
const response = await request("/sessions/devices", { signal });
return parseResponse<DeviceRepresentation[]>(response);
}
export async function getApplications({ signal }: CallOptions = {}): Promise<
ClientRepresentation[]
> {
@ -44,3 +52,9 @@ export async function getApplications({ signal }: CallOptions = {}): Promise<
export async function deleteConsent(id: string) {
return request(`/applications/${id}/consent`, { method: "DELETE" });
}
export async function deleteSession(id?: string) {
return request(`"/sessions${id ? `/${id}` : ""}`, {
method: "DELETE",
});
}

View file

@ -0,0 +1,29 @@
const DATE_AND_TIME_FORMAT: Intl.DateTimeFormatOptions = {
dateStyle: "long",
timeStyle: "short",
};
const TIME_FORMAT: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
};
export default function useFormatter() {
return {
formatDate: function (
date: Date,
options: Intl.DateTimeFormatOptions | undefined = DATE_AND_TIME_FORMAT
) {
return date.toLocaleString("en", options);
},
formatTime: function (
time: number,
options: Intl.DateTimeFormatOptions | undefined = TIME_FORMAT
) {
return new Intl.DateTimeFormat("en", options).format(time);
},
};
}