diff --git a/frontend/src/components/BrowserSession.tsx b/frontend/src/components/BrowserSession.tsx index dbc4db06..9e6ede77 100644 --- a/frontend/src/components/BrowserSession.tsx +++ b/frontend/src/components/BrowserSession.tsx @@ -124,6 +124,7 @@ const BrowserSession: React.FC = ({ session }) => { createdAt={createdAt} finishedAt={finishedAt} isCurrent={isCurrent} + deviceType={deviceInformation?.deviceType} lastActiveIp={data.lastActiveIp || undefined} lastActiveAt={lastActiveAt} > diff --git a/frontend/src/components/OAuth2Session.test.tsx b/frontend/src/components/OAuth2Session.test.tsx index f632cf15..ee471d1e 100644 --- a/frontend/src/components/OAuth2Session.test.tsx +++ b/frontend/src/components/OAuth2Session.test.tsx @@ -18,6 +18,7 @@ import { create } from "react-test-renderer"; import { describe, expect, it, beforeAll } from "vitest"; import { makeFragmentData } from "../gql"; +import { Oauth2ApplicationType } from "../gql/graphql"; import { WithLocation } from "../test-utils/WithLocation"; import { mockLocale } from "../test-utils/mockLocale"; @@ -35,6 +36,7 @@ describe("", () => { clientId: "test-client-id", clientName: "Element", clientUri: "https://element.io", + applicationType: Oauth2ApplicationType.Web, }, }; @@ -68,4 +70,24 @@ describe("", () => { ); expect(component.toJSON()).toMatchSnapshot(); }); + + it("renders correct icon for a native session", () => { + const session = makeFragmentData( + { + ...defaultSession, + finishedAt, + client: { + ...defaultSession.client, + applicationType: Oauth2ApplicationType.Native, + }, + }, + FRAGMENT, + ); + const component = create( + + + , + ); + expect(component.toJSON()).toMatchSnapshot(); + }); }); diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx index 6ba37209..487f9026 100644 --- a/frontend/src/components/OAuth2Session.tsx +++ b/frontend/src/components/OAuth2Session.tsx @@ -18,8 +18,10 @@ import { atomFamily } from "jotai/utils"; import { atomWithMutation } from "jotai-urql"; import { FragmentType, graphql, useFragment } from "../gql"; +import { Oauth2ApplicationType } from "../gql/graphql"; import { Link } from "../routing"; import { getDeviceIdFromScope } from "../utils/deviceIdFromScope"; +import { DeviceType } from "../utils/parseUserAgent"; import { Session } from "./Session"; import EndSessionButton from "./Session/EndSessionButton"; @@ -36,6 +38,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` id clientId clientName + applicationType logoUri } } @@ -53,6 +56,18 @@ const END_SESSION_MUTATION = graphql(/* GraphQL */ ` } `); +const getDeviceTypeFromClientAppType = ( + appType?: Oauth2ApplicationType | null, +): DeviceType => { + if (appType === Oauth2ApplicationType.Web) { + return DeviceType.Web; + } + if (appType === Oauth2ApplicationType.Native) { + return DeviceType.Mobile; + } + return DeviceType.Unknown; +}; + export const endSessionFamily = atomFamily((id: string) => { const endSession = atomWithMutation(END_SESSION_MUTATION); @@ -89,6 +104,10 @@ const OAuth2Session: React.FC = ({ session }) => { ? parseISO(data.lastActiveAt) : undefined; + const deviceType = getDeviceTypeFromClientAppType( + data.client.applicationType, + ); + return ( = ({ session }) => { finishedAt={finishedAt} clientName={data.client.clientName || data.client.clientId || undefined} clientLogoUri={data.client.logoUri || undefined} + deviceType={deviceType} lastActiveIp={data.lastActiveIp || undefined} lastActiveAt={lastActiveAt} > diff --git a/frontend/src/components/Session/DeviceTypeIcon.module.css b/frontend/src/components/Session/DeviceTypeIcon.module.css new file mode 100644 index 00000000..5108dff5 --- /dev/null +++ b/frontend/src/components/Session/DeviceTypeIcon.module.css @@ -0,0 +1,25 @@ +/* Copyright 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.icon { + color: var(--cpd-color-icon-primary); + background-color: var(--cpd-color-bg-subtle-primary); + flex: 0 0 var(--cpd-space-4x); + box-sizing: content-box; + height: var(--cpd-space-4x); + width: var(--cpd-space-4x); + padding: var(--cpd-space-2x); + border-radius: var(--cpd-space-2x); +} \ No newline at end of file diff --git a/frontend/src/components/Session/DeviceTypeIcon.stories.tsx b/frontend/src/components/Session/DeviceTypeIcon.stories.tsx new file mode 100644 index 00000000..bea5ea98 --- /dev/null +++ b/frontend/src/components/Session/DeviceTypeIcon.stories.tsx @@ -0,0 +1,60 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Meta, StoryObj } from "@storybook/react"; + +import { DeviceType } from "../../utils/parseUserAgent"; + +import DeviceTypeIcon from "./DeviceTypeIcon"; + +const meta = { + title: "UI/Session/Device Type Icon", + component: DeviceTypeIcon, + tags: ["autodocs"], + args: { + deviceType: DeviceType.Unknown, + }, + argTypes: { + deviceType: { + control: "select", + options: [ + DeviceType.Unknown, + DeviceType.Desktop, + DeviceType.Mobile, + DeviceType.Web, + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unknown: Story = {}; + +export const Desktop: Story = { + args: { + deviceType: DeviceType.Desktop, + }, +}; +export const Mobile: Story = { + args: { + deviceType: DeviceType.Mobile, + }, +}; +export const Web: Story = { + args: { + deviceType: DeviceType.Web, + }, +}; diff --git a/frontend/src/components/Session/DeviceTypeIcon.test.tsx b/frontend/src/components/Session/DeviceTypeIcon.test.tsx new file mode 100644 index 00000000..ef6fa4f5 --- /dev/null +++ b/frontend/src/components/Session/DeviceTypeIcon.test.tsx @@ -0,0 +1,46 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @vitest-environment happy-dom + +import { composeStory } from "@storybook/react"; +import { render, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach } from "vitest"; + +import Meta, { Unknown, Desktop, Mobile, Web } from "./DeviceTypeIcon.stories"; + +describe("", () => { + afterEach(cleanup); + + it("renders unknown device type", () => { + const Component = composeStory(Unknown, Meta); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders mobile device type", () => { + const Component = composeStory(Mobile, Meta); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders desktop device type", () => { + const Component = composeStory(Desktop, Meta); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders Web device type", () => { + const Component = composeStory(Web, Meta); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/components/Session/DeviceTypeIcon.tsx b/frontend/src/components/Session/DeviceTypeIcon.tsx new file mode 100644 index 00000000..0417681c --- /dev/null +++ b/frontend/src/components/Session/DeviceTypeIcon.tsx @@ -0,0 +1,51 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import IconComputer from "@vector-im/compound-design-tokens/icons/computer.svg?react"; +import IconMobile from "@vector-im/compound-design-tokens/icons/mobile.svg?react"; +import IconUnknown from "@vector-im/compound-design-tokens/icons/unknown.svg?react"; +import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg?react"; +import { FunctionComponent, SVGProps } from "react"; + +import { DeviceType } from "../../utils/parseUserAgent"; + +import styles from "./DeviceTypeIcon.module.css"; + +const deviceTypeToIcon: Record< + DeviceType, + FunctionComponent & { title?: string | undefined }> +> = { + [DeviceType.Unknown]: IconUnknown, + [DeviceType.Desktop]: IconComputer, + [DeviceType.Mobile]: IconMobile, + [DeviceType.Web]: IconBrowser, +}; + +const deviceTypeToLabel: Record = { + [DeviceType.Unknown]: "Unknown device type", + [DeviceType.Desktop]: "Desktop", + [DeviceType.Mobile]: "Mobile", + [DeviceType.Web]: "Web", +}; + +const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({ + deviceType, +}) => { + const Icon = deviceTypeToIcon[deviceType]; + const label = deviceTypeToLabel[deviceType]; + + return ; +}; + +export default DeviceTypeIcon; diff --git a/frontend/src/components/Session/Session.module.css b/frontend/src/components/Session/Session.module.css index e268ad0f..24e62b62 100644 --- a/frontend/src/components/Session/Session.module.css +++ b/frontend/src/components/Session/Session.module.css @@ -13,10 +13,24 @@ * limitations under the License. */ +.session { + display: flex; + flex-direction: row; + justify-content: flex-start; + gap:var(--cpd-space-4x); +} + +.container { + display: flex; + flex: 1 1 100%; + flex-direction: column; + align-items: flex-start; +} + .session-name { overflow: hidden; text-overflow: ellipsis; - margin-top: var(--cpd-space-2x); + margin-top: var(--cpd-space-1x); margin-bottom: var(--cpd-space-1x); } @@ -37,4 +51,5 @@ display: flex; flex-direction: row; justify-content: flex-end; + width: 100%; } \ No newline at end of file diff --git a/frontend/src/components/Session/Session.tsx b/frontend/src/components/Session/Session.tsx index 32e0fb44..197585e0 100644 --- a/frontend/src/components/Session/Session.tsx +++ b/frontend/src/components/Session/Session.tsx @@ -15,10 +15,12 @@ import { H6, Body, Badge } from "@vector-im/compound-web"; import { ReactNode } from "react"; +import { DeviceType } from "../../utils/parseUserAgent"; import Block from "../Block"; import DateTime from "../DateTime"; import ClientAvatar from "./ClientAvatar"; +import DeviceTypeIcon from "./DeviceTypeIcon"; import LastActive from "./LastActive"; import styles from "./Session.module.css"; @@ -34,6 +36,7 @@ type SessionProps = { clientName?: string; clientLogoUri?: string; isCurrent?: boolean; + deviceType?: DeviceType; lastActiveIp?: string; lastActiveAt?: Date; }; @@ -48,40 +51,44 @@ const Session: React.FC> = ({ lastActiveAt, isCurrent, children, + deviceType, }) => { return ( - - {isCurrent && Current} -
- {name || id} -
- - Signed in - - {!!finishedAt && ( - - Finished + + +
+ {isCurrent && Current} +
+ {name || id} +
+ + Signed in - )} - {!!lastActiveAt && ( - - - - )} - {!!lastActiveIp && {lastActiveIp}} - {!!clientName && ( - - {" "} - - {clientName} + {!!finishedAt && ( + + Finished - - )} - {!!children &&
{children}
} + )} + {!!lastActiveAt && ( + + + + )} + {!!lastActiveIp && {lastActiveIp}} + {!!clientName && ( + + {" "} + + {clientName} + + + )} + {!!children &&
{children}
} +
); }; diff --git a/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap b/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap new file mode 100644 index 00000000..3e124fa7 --- /dev/null +++ b/frontend/src/components/Session/__snapshots__/DeviceTypeIcon.test.tsx.snap @@ -0,0 +1,85 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders Web device type 1`] = ` +
+ + + +
+`; + +exports[` > renders desktop device type 1`] = ` +
+ + + +
+`; + +exports[` > renders mobile device type 1`] = ` +
+ + + +
+`; + +exports[` > renders unknown device type 1`] = ` +
+ + + + + +
+`; diff --git a/frontend/src/components/Session/__snapshots__/Session.test.tsx.snap b/frontend/src/components/Session/__snapshots__/Session.test.tsx.snap index cb5febde..a4a4a6b6 100644 --- a/frontend/src/components/Session/__snapshots__/Session.test.tsx.snap +++ b/frontend/src/components/Session/__snapshots__/Session.test.tsx.snap @@ -2,195 +2,325 @@ exports[` > renders a finished session 1`] = `
-
- session-id -
-

+ + + +

- Signed in - -

-

- Finished -

- Thu, 29 Jun 2023, 03:35 - -

+ Signed in + +

+

+ Finished + +

+
`; exports[` > renders an active session 1`] = `
-
- session-id -
-

+ + + +

- Signed in - -

+ session-id + +

+ Signed in + +

+
`; exports[` > renders ip address 1`] = `
-
- session-id -
-

+ + + +

- Signed in - -

-

- Finished - -

-

- 127.0.0.1 -

-

- - +

- Element - -

+ Signed in + +

+

+ Finished + +

+

+ 127.0.0.1 +

+

+ + + Element + +

+
`; exports[` > uses client name when truthy 1`] = `
-
- session-id -
-

- Signed in - -

-

- Finished - -

-

- Element - - + + +

+
+ session-id +
+

- Element - -

+ Signed in + +

+

+ Finished + +

+

+ Element + + + Element + +

+
`; exports[` > uses session name when truthy 1`] = `
-
- test session name -
-

+ + + +

- Signed in - -

-

- Finished -

- Thu, 29 Jun 2023, 03:35 - -

+ Signed in + +

+

+ Finished + +

+
`; diff --git a/frontend/src/components/__snapshots__/CompatSession.test.tsx.snap b/frontend/src/components/__snapshots__/CompatSession.test.tsx.snap index e6d0b52f..bafa48be 100644 --- a/frontend/src/components/__snapshots__/CompatSession.test.tsx.snap +++ b/frontend/src/components/__snapshots__/CompatSession.test.tsx.snap @@ -2,119 +2,171 @@ exports[` > renders a finished session 1`] = `
-
- + + + +
+
- abcd1234 - -
-

- Signed in - -

-

- Finished - -

-

- 1.2.3.4 -

-

- - + abcd1234 + +

+

- element.io - -

+ Signed in + +

+

+ Finished + +

+

+ 1.2.3.4 +

+

+ + + element.io + +

+
`; exports[` > renders an active session 1`] = `
-
- + + + +
+
- abcd1234 - -
-

- Signed in - -

-

- 1.2.3.4 -

-

- - + abcd1234 + +

+

- element.io - -

-
- + 1.2.3.4 +

+

+ + + element.io + +

+
+ +
`; diff --git a/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap b/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap index ec2a1909..b38af77a 100644 --- a/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap +++ b/frontend/src/components/__snapshots__/OAuth2Session.test.tsx.snap @@ -2,119 +2,228 @@ exports[` > renders a finished session 1`] = `
-
- + +
+
- abcd1234 - -
-

- Signed in - -

-

- Finished - -

-

- 1.2.3.4 -

-

- - + abcd1234 + +

+

- Element - -

+ Signed in + +

+

+ Finished + +

+

+ 1.2.3.4 +

+

+ + + Element + +

+
`; exports[` > renders an active session 1`] = `
-
- + +
+
- abcd1234 - -
-

- Signed in - -

-

- 1.2.3.4 -

-

- - + abcd1234 + +

+

- Element - -

-
- + 1.2.3.4 +

+

+ + + Element + +

+
+ +
+
+
+`; + +exports[` > renders correct icon for a native session 1`] = ` +
+ + + +
+
+ + abcd1234 + +
+

+ Signed in + +

+

+ Finished + +

+

+ 1.2.3.4 +

+

+ + + Element + +

`; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 9a127650..cf1d6d2c 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -29,7 +29,7 @@ const documents = { types.CompatSession_SessionFragmentDoc, "\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n": types.EndCompatSessionDocument, - "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n logoUri\n }\n }\n": + "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, "\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n": types.EndOAuth2SessionDocument, @@ -145,8 +145,8 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n logoUri\n }\n }\n", -): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n logoUri\n }\n }\n"]; + source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n", +): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 3e733ba4..b2aade61 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1208,6 +1208,7 @@ export type OAuth2Session_SessionFragment = { id: string; clientId: string; clientName?: string | null; + applicationType?: Oauth2ApplicationType | null; logoUri?: string | null; }; } & { " $fragmentName"?: "OAuth2Session_SessionFragment" }; @@ -1743,6 +1744,10 @@ export const OAuth2Session_SessionFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "clientId" } }, { kind: "Field", name: { kind: "Name", value: "clientName" } }, + { + kind: "Field", + name: { kind: "Name", value: "applicationType" }, + }, { kind: "Field", name: { kind: "Name", value: "logoUri" } }, ], }, @@ -2600,6 +2605,10 @@ export const EndOAuth2SessionDocument = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "clientId" } }, { kind: "Field", name: { kind: "Name", value: "clientName" } }, + { + kind: "Field", + name: { kind: "Name", value: "applicationType" }, + }, { kind: "Field", name: { kind: "Name", value: "logoUri" } }, ], }, @@ -3733,6 +3742,10 @@ export const AppSessionListDocument = { { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "clientId" } }, { kind: "Field", name: { kind: "Name", value: "clientName" } }, + { + kind: "Field", + name: { kind: "Name", value: "applicationType" }, + }, { kind: "Field", name: { kind: "Name", value: "logoUri" } }, ], }, diff --git a/frontend/src/utils/parseUserAgent.test.ts b/frontend/src/utils/parseUserAgent.test.ts index 40df5026..8bce96b2 100644 --- a/frontend/src/utils/parseUserAgent.test.ts +++ b/frontend/src/utils/parseUserAgent.test.ts @@ -233,20 +233,20 @@ const WEB_EXPECTED_RESULT = [ "8.0", ), makeDeviceExtendedInfo( - DeviceType.Web, + DeviceType.Mobile, "Apple", "iPhone", "iOS", - undefined, + "8.4.1", "Mobile Safari", "8.0", ), makeDeviceExtendedInfo( - DeviceType.Web, + DeviceType.Mobile, "Samsung", "SM-G973U", "Android", - undefined, + "9", "Chrome", "69.0.3497.100", ), diff --git a/frontend/src/utils/parseUserAgent.ts b/frontend/src/utils/parseUserAgent.ts index a234250e..248b88e4 100644 --- a/frontend/src/utils/parseUserAgent.ts +++ b/frontend/src/utils/parseUserAgent.ts @@ -48,9 +48,6 @@ const getDeviceType = ( if (browser.name === "Electron") { return DeviceType.Desktop; } - if (browser.name) { - return DeviceType.Web; - } if ( device.type === "mobile" || operatingSystem.name?.includes("Android") || @@ -58,6 +55,9 @@ const getDeviceType = ( ) { return DeviceType.Mobile; } + if (browser.name) { + return DeviceType.Web; + } return DeviceType.Unknown; };