diff --git a/frontend/codegen.ts b/frontend/codegen.ts index e0efa32f..b8372119 100644 --- a/frontend/codegen.ts +++ b/frontend/codegen.ts @@ -21,6 +21,14 @@ const config: CodegenConfig = { generates: { "./src/gql/": { preset: "client", + config: { + // By default, unknown scalars are generated as `any`. This is not ideal for catching potential bugs. + defaultScalarType: "unknown", + scalars: { + DateTime: "string", + Url: "string", + }, + }, }, "./src/gql/schema.ts": { plugins: ["urql-introspection"], diff --git a/frontend/src/components/BrowserSession.tsx b/frontend/src/components/BrowserSession.tsx index 17f14e2c..dbc4db06 100644 --- a/frontend/src/components/BrowserSession.tsx +++ b/frontend/src/components/BrowserSession.tsx @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { parseISO } from "date-fns"; import { atom, useSetAtom } from "jotai"; import { atomFamily } from "jotai/utils"; import { atomWithMutation } from "jotai-urql"; @@ -29,12 +30,14 @@ import { useCurrentBrowserSessionId } from "../utils/session/useCurrentBrowserSe import EndSessionButton from "./Session/EndSessionButton"; import Session from "./Session/Session"; -export const BROWSER_SESSION_FRAGMENT = graphql(/* GraphQL */ ` +const FRAGMENT = graphql(/* GraphQL */ ` fragment BrowserSession_session on BrowserSession { id createdAt finishedAt userAgent + lastActiveIp + lastActiveAt lastAuthentication { id createdAt @@ -92,17 +95,21 @@ export const useEndBrowserSession = ( }; type Props = { - session: FragmentType; + session: FragmentType; }; const BrowserSession: React.FC = ({ session }) => { const currentBrowserSessionId = useCurrentBrowserSessionId(); - const data = useFragment(BROWSER_SESSION_FRAGMENT, session); + const data = useFragment(FRAGMENT, session); const isCurrent = data.id === currentBrowserSessionId; const onSessionEnd = useEndBrowserSession(data.id, isCurrent); - const createdAt = data.createdAt; + const createdAt = parseISO(data.createdAt); + const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined; + const lastActiveAt = data.lastActiveAt + ? parseISO(data.lastActiveAt) + : undefined; const deviceInformation = parseUserAgent(data.userAgent || undefined); const sessionName = sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; @@ -115,8 +122,10 @@ const BrowserSession: React.FC = ({ session }) => { id={data.id} name={name} createdAt={createdAt} - finishedAt={data.finishedAt} + finishedAt={finishedAt} isCurrent={isCurrent} + lastActiveIp={data.lastActiveIp || undefined} + lastActiveAt={lastActiveAt} > {!data.finishedAt && } diff --git a/frontend/src/components/Client/OAuth2ClientDetail.tsx b/frontend/src/components/Client/OAuth2ClientDetail.tsx index 62d29307..88a8803a 100644 --- a/frontend/src/components/Client/OAuth2ClientDetail.tsx +++ b/frontend/src/components/Client/OAuth2ClientDetail.tsx @@ -70,7 +70,7 @@ const OAuth2ClientDetail: React.FC = ({ client }) => {
diff --git a/frontend/src/components/CompatSession.test.tsx b/frontend/src/components/CompatSession.test.tsx index 86ebba7a..1526c136 100644 --- a/frontend/src/components/CompatSession.test.tsx +++ b/frontend/src/components/CompatSession.test.tsx @@ -17,28 +17,30 @@ import { create } from "react-test-renderer"; import { describe, expect, it, beforeAll } from "vitest"; -import { FragmentType } from "../gql/fragment-masking"; +import { makeFragmentData } from "../gql"; import { WithLocation } from "../test-utils/WithLocation"; import { mockLocale } from "../test-utils/mockLocale"; -import CompatSession, { COMPAT_SESSION_FRAGMENT } from "./CompatSession"; +import CompatSession, { FRAGMENT } from "./CompatSession"; describe("", () => { - const session = { + const baseSession = { id: "session-id", deviceId: "abcd1234", createdAt: "2023-06-29T03:35:17.451292+00:00", + lastActiveIp: "1.2.3.4", ssoLogin: { id: "test-id", redirectUri: "https://element.io", }, - } as FragmentType; + }; const finishedAt = "2023-06-29T03:35:19.451292+00:00"; beforeAll(() => mockLocale()); it("renders an active session", () => { + const session = makeFragmentData(baseSession, FRAGMENT); const component = create( @@ -48,13 +50,16 @@ describe("", () => { }); it("renders a finished session", () => { - const finishedSession = { - ...session, - finishedAt, - }; + const session = makeFragmentData( + { + ...baseSession, + finishedAt, + }, + FRAGMENT, + ); const component = create( - + , ); expect(component.toJSON()).toMatchSnapshot(); diff --git a/frontend/src/components/CompatSession.tsx b/frontend/src/components/CompatSession.tsx index d6edf94f..d70008e0 100644 --- a/frontend/src/components/CompatSession.tsx +++ b/frontend/src/components/CompatSession.tsx @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { parseISO } from "date-fns"; import { atom, useSetAtom } from "jotai"; import { atomFamily } from "jotai/utils"; import { atomWithMutation } from "jotai-urql"; @@ -22,12 +23,14 @@ import { Link } from "../routing"; import { Session } from "./Session"; import EndSessionButton from "./Session/EndSessionButton"; -export const COMPAT_SESSION_FRAGMENT = graphql(/* GraphQL */ ` +export const FRAGMENT = graphql(/* GraphQL */ ` fragment CompatSession_session on CompatSession { id createdAt deviceId finishedAt + lastActiveIp + lastActiveAt ssoLogin { id redirectUri @@ -81,9 +84,9 @@ export const simplifyUrl = (url: string): string => { }; const CompatSession: React.FC<{ - session: FragmentType; + session: FragmentType; }> = ({ session }) => { - const data = useFragment(COMPAT_SESSION_FRAGMENT, session); + const data = useFragment(FRAGMENT, session); const endCompatSession = useSetAtom(endCompatSessionFamily(data.id)); const onSessionEnd = async (): Promise => { @@ -98,13 +101,21 @@ const CompatSession: React.FC<{ ? simplifyUrl(data.ssoLogin.redirectUri) : undefined; + const createdAt = parseISO(data.createdAt); + const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined; + const lastActiveAt = data.lastActiveAt + ? parseISO(data.lastActiveAt) + : undefined; + return ( {!data.finishedAt && } diff --git a/frontend/src/components/OAuth2Session.test.tsx b/frontend/src/components/OAuth2Session.test.tsx index f39fbdbf..f632cf15 100644 --- a/frontend/src/components/OAuth2Session.test.tsx +++ b/frontend/src/components/OAuth2Session.test.tsx @@ -17,26 +17,25 @@ import { create } from "react-test-renderer"; import { describe, expect, it, beforeAll } from "vitest"; -import { FragmentType } from "../gql/fragment-masking"; +import { makeFragmentData } from "../gql"; import { WithLocation } from "../test-utils/WithLocation"; import { mockLocale } from "../test-utils/mockLocale"; -import OAuth2Session, { OAUTH2_SESSION_FRAGMENT } from "./OAuth2Session"; +import OAuth2Session, { FRAGMENT } from "./OAuth2Session"; describe("", () => { - const defaultProps = { - session: { - id: "session-id", - scope: - "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:abcd1234", - createdAt: "2023-06-29T03:35:17.451292+00:00", - client: { - id: "test-id", - clientId: "test-client-id", - clientName: "Element", - clientUri: "https://element.io", - }, - } as FragmentType, + const defaultSession = { + id: "session-id", + scope: + "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:abcd1234", + createdAt: "2023-06-29T03:35:17.451292+00:00", + lastActiveIp: "1.2.3.4", + client: { + id: "test-id", + clientId: "test-client-id", + clientName: "Element", + clientUri: "https://element.io", + }, }; const finishedAt = "2023-06-29T03:35:19.451292+00:00"; @@ -44,22 +43,27 @@ describe("", () => { beforeAll(() => mockLocale()); it("renders an active session", () => { + const session = makeFragmentData(defaultSession, FRAGMENT); + const component = create( - + , ); expect(component.toJSON()).toMatchSnapshot(); }); it("renders a finished session", () => { - const finishedSession = { - ...defaultProps.session, - finishedAt, - }; + const session = makeFragmentData( + { + ...defaultSession, + finishedAt, + }, + FRAGMENT, + ); const component = create( - + , ); expect(component.toJSON()).toMatchSnapshot(); diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx index eb1c0e4e..6ba37209 100644 --- a/frontend/src/components/OAuth2Session.tsx +++ b/frontend/src/components/OAuth2Session.tsx @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { parseISO } from "date-fns"; import { atom, useSetAtom } from "jotai"; import { atomFamily } from "jotai/utils"; import { atomWithMutation } from "jotai-urql"; @@ -23,36 +24,23 @@ import { getDeviceIdFromScope } from "../utils/deviceIdFromScope"; import { Session } from "./Session"; import EndSessionButton from "./Session/EndSessionButton"; -export const OAUTH2_SESSION_FRAGMENT = graphql(/* GraphQL */ ` +export const FRAGMENT = graphql(/* GraphQL */ ` fragment OAuth2Session_session on Oauth2Session { id scope createdAt finishedAt + lastActiveIp + lastActiveAt client { id clientId clientName - clientUri logoUri } } `); -export type Oauth2SessionType = { - id: string; - scope: string; - createdAt: string; - finishedAt: string | null; - client: { - id: string; - clientId: string; - clientName: string; - clientUri: string; - logoUri: string | null; - }; -}; - const END_SESSION_MUTATION = graphql(/* GraphQL */ ` mutation EndOAuth2Session($id: ID!) { endOauth2Session(input: { oauth2SessionId: $id }) { @@ -78,14 +66,11 @@ export const endSessionFamily = atomFamily((id: string) => { }); type Props = { - session: FragmentType; + session: FragmentType; }; const OAuth2Session: React.FC = ({ session }) => { - const data = useFragment( - OAUTH2_SESSION_FRAGMENT, - session, - ) as Oauth2SessionType; + const data = useFragment(FRAGMENT, session); const endSession = useSetAtom(endSessionFamily(data.id)); const onSessionEnd = async (): Promise => { @@ -98,14 +83,22 @@ const OAuth2Session: React.FC = ({ session }) => { {deviceId} ); + const createdAt = parseISO(data.createdAt); + const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined; + const lastActiveAt = data.lastActiveAt + ? parseISO(data.lastActiveAt) + : undefined; + return ( {!data.finishedAt && } diff --git a/frontend/src/components/Session/LastActive.stories.tsx b/frontend/src/components/Session/LastActive.stories.tsx index b4e0d937..16411aaf 100644 --- a/frontend/src/components/Session/LastActive.stories.tsx +++ b/frontend/src/components/Session/LastActive.stories.tsx @@ -13,40 +13,44 @@ // limitations under the License. import type { Meta, StoryObj } from "@storybook/react"; +import { parseISO, subDays, subHours } from "date-fns"; import LastActive from "./LastActive"; -type Props = { - lastActiveTimestamp: number; - now: number; -}; -const Template: React.FC = ({ lastActiveTimestamp, now }) => { - return ; -}; - const meta = { title: "UI/Session/Last active time", - component: Template, + component: LastActive, + argTypes: { + lastActive: { control: { type: "date" } }, + now: { control: { type: "date" } }, + }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; -const now = 1694999531800; -const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const now = parseISO("2023-09-18T01:12:00.000Z"); export const Basic: Story = { args: { - // yesterday - lastActiveTimestamp: now - ONE_DAY_MS, + // An hour ago + lastActive: subHours(now, 1), + now, + }, +}; + +export const ActiveThreeDaysAgo: Story = { + args: { + // Three days ago + lastActive: subDays(now, 3), now, }, }; export const ActiveNow: Story = { args: { - lastActiveTimestamp: now - 1000, + lastActive: now, now, }, }; @@ -54,7 +58,7 @@ export const ActiveNow: Story = { export const Inactive: Story = { args: { // 91 days ago - lastActiveTimestamp: now - 91 * ONE_DAY_MS, + lastActive: subDays(now, 91), now, }, }; diff --git a/frontend/src/components/Session/LastActive.test.tsx b/frontend/src/components/Session/LastActive.test.tsx index 378f3a9b..84f6969a 100644 --- a/frontend/src/components/Session/LastActive.test.tsx +++ b/frontend/src/components/Session/LastActive.test.tsx @@ -20,7 +20,12 @@ import { describe, afterEach, expect, it, beforeAll } from "vitest"; import { mockLocale } from "../../test-utils/mockLocale"; -import Meta, { ActiveNow, Basic, Inactive } from "./LastActive.stories"; +import Meta, { + ActiveNow, + ActiveThreeDaysAgo, + Basic, + Inactive, +} from "./LastActive.stories"; describe(" { beforeAll(() => mockLocale()); @@ -33,6 +38,12 @@ describe(" { }); it("renders a default timestamp", () => { + const Component = composeStory(ActiveThreeDaysAgo, Meta); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders a relative timestamp", () => { const Component = composeStory(Basic, Meta); const { container } = render(); expect(container).toMatchSnapshot(); diff --git a/frontend/src/components/Session/LastActive.tsx b/frontend/src/components/Session/LastActive.tsx index d5cd9360..6e06efef 100644 --- a/frontend/src/components/Session/LastActive.tsx +++ b/frontend/src/components/Session/LastActive.tsx @@ -12,35 +12,44 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { differenceInSeconds, parseISO } from "date-fns"; + import { formatDate, formatReadableDate } from "../DateTime"; import styles from "./LastActive.module.css"; // 3 minutes -const ACTIVE_NOW_MAX_AGE = 1000 * 60 * 3; +const ACTIVE_NOW_MAX_AGE = 60 * 3; /// 90 days -const INACTIVE_MIN_AGE = 1000 * 60 * 60 * 24 * 90; +const INACTIVE_MIN_AGE = 60 * 60 * 24 * 90; -const LastActive: React.FC<{ lastActiveTimestamp: number; now?: number }> = ({ - lastActiveTimestamp, - now: nowProps, -}) => { - const now = nowProps || Date.now(); - const formattedDate = formatDate(new Date(lastActiveTimestamp)); - if (lastActiveTimestamp >= now - ACTIVE_NOW_MAX_AGE) { +const LastActive: React.FC<{ + lastActive: Date | string; + now?: Date | string; +}> = ({ lastActive: lastActiveProps, now: nowProps }) => { + const lastActive = + typeof lastActiveProps === "string" + ? parseISO(lastActiveProps) + : lastActiveProps; + + const now = nowProps + ? typeof nowProps === "string" + ? parseISO(nowProps) + : nowProps + : new Date(); + + const formattedDate = formatDate(lastActive); + if (differenceInSeconds(now, lastActive) <= ACTIVE_NOW_MAX_AGE) { return ( Active now ); } - if (lastActiveTimestamp < now - INACTIVE_MIN_AGE) { + if (differenceInSeconds(now, lastActive) > INACTIVE_MIN_AGE) { return Inactive for 90+ days; } - const relativeDate = formatReadableDate( - new Date(lastActiveTimestamp), - new Date(now), - ); + const relativeDate = formatReadableDate(lastActive, now); return {`Active ${relativeDate}`}; }; diff --git a/frontend/src/components/Session/Session.stories.tsx b/frontend/src/components/Session/Session.stories.tsx index 402d79c4..e5a92e51 100644 --- a/frontend/src/components/Session/Session.stories.tsx +++ b/frontend/src/components/Session/Session.stories.tsx @@ -14,20 +14,24 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Button } from "@vector-im/compound-web"; +import { parseISO } from "date-fns"; import { ReactElement } from "react"; import BlockList from "../BlockList/BlockList"; -import Session, { SessionProps } from "./Session"; - -const Template: React.FC> = (props) => { - return ; -}; +import Session from "./Session"; const meta = { title: "UI/Session/Session", - component: Template, + component: Session, tags: ["autodocs"], + + argTypes: { + createdAt: { control: { type: "date" } }, + finishedAt: { control: { type: "date" } }, + lastActiveAt: { control: { type: "date" } }, + }, + decorators: [ (Story): ReactElement => (
@@ -37,21 +41,22 @@ const meta = {
), ], -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; const defaultProps = { id: "oauth2_session:01H5VAGA5NYTKJVXP3HMMKDJQ0", - createdAt: "2023-06-29T03:35:17.451292+00:00", + createdAt: parseISO("2023-06-29T03:35:17.451292+00:00"), }; export const BasicSession: Story = { args: { ...defaultProps, name: "KlTqK9CRt3", - ipAddress: "2001:8003:c4614:f501:3091:888a:49c7", + lastActiveIp: "2001:8003:c4614:f501:3091:888a:49c7", + lastActiveAt: parseISO("2023-07-29T03:35:17.451292+00:00"), clientName: "Element", }, }; @@ -60,7 +65,7 @@ export const BasicFinishedSession: Story = { args: { ...defaultProps, name: "Chrome on Android", - finishedAt: "2023-06-30T03:35:17.451292+00:00", + finishedAt: parseISO("2023-06-30T03:35:17.451292+00:00"), }, }; diff --git a/frontend/src/components/Session/Session.test.tsx b/frontend/src/components/Session/Session.test.tsx index dbfba4db..434c057b 100644 --- a/frontend/src/components/Session/Session.test.tsx +++ b/frontend/src/components/Session/Session.test.tsx @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { parseISO } from "date-fns"; import { create } from "react-test-renderer"; import { describe, expect, it, beforeAll } from "vitest"; @@ -22,10 +23,10 @@ import Session from "./Session"; describe("", () => { const defaultProps = { id: "session-id", - createdAt: "2023-06-29T03:35:17.451292+00:00", + createdAt: parseISO("2023-06-29T03:35:17.451292+00:00"), }; - const finishedAt = "2023-06-29T03:35:19.451292+00:00"; + const finishedAt = parseISO("2023-06-29T03:35:19.451292+00:00"); beforeAll(() => mockLocale()); @@ -69,7 +70,7 @@ describe("", () => { {...defaultProps} finishedAt={finishedAt} clientName={clientName} - ipAddress="127.0.0.1" + lastActiveIp="127.0.0.1" />, ); expect(component.toJSON()).toMatchSnapshot(); diff --git a/frontend/src/components/Session/Session.tsx b/frontend/src/components/Session/Session.tsx index 3d7d04a1..32e0fb44 100644 --- a/frontend/src/components/Session/Session.tsx +++ b/frontend/src/components/Session/Session.tsx @@ -19,21 +19,23 @@ import Block from "../Block"; import DateTime from "../DateTime"; import ClientAvatar from "./ClientAvatar"; +import LastActive from "./LastActive"; import styles from "./Session.module.css"; const SessionMetadata: React.FC> = ( props, ) => ; -export type SessionProps = { +type SessionProps = { id: string; name?: string | ReactNode; - createdAt: string; - finishedAt?: string; + createdAt: Date; + finishedAt?: Date; clientName?: string; clientLogoUri?: string; isCurrent?: boolean; - ipAddress?: string; + lastActiveIp?: string; + lastActiveAt?: Date; }; const Session: React.FC> = ({ id, @@ -42,7 +44,8 @@ const Session: React.FC> = ({ finishedAt, clientName, clientLogoUri, - ipAddress, + lastActiveIp, + lastActiveAt, isCurrent, children, }) => { @@ -60,7 +63,12 @@ const Session: React.FC> = ({ Finished )} - {!!ipAddress && {ipAddress}} + {!!lastActiveAt && ( + + + + )} + {!!lastActiveIp && {lastActiveIp}} {!!clientName && ( renders a default timestamp 1`] = `
- Active Sun, 17 Sept 2023, 01:12 + Active Fri, 15 Sept 2023, 01:12 + +
+`; + +exports[` renders a relative timestamp 1`] = ` +
+ + Active 1 hour ago
`; diff --git a/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx b/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx index c0fcc48c..b63d8122 100644 --- a/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx @@ -13,9 +13,9 @@ // limitations under the License. import { Badge } from "@vector-im/compound-web"; +import { parseISO } from "date-fns"; -import { FragmentType, useFragment } from "../../gql"; -import { BROWSER_SESSION_DETAIL_FRAGMENT } from "../../pages/BrowserSession"; +import { FragmentType, graphql, useFragment } from "../../gql"; import { parseUserAgent, sessionNameFromDeviceInformation, @@ -25,17 +25,37 @@ import BlockList from "../BlockList/BlockList"; import { useEndBrowserSession } from "../BrowserSession"; import DateTime from "../DateTime"; import EndSessionButton from "../Session/EndSessionButton"; +import LastActive from "../Session/LastActive"; import styles from "./BrowserSessionDetail.module.css"; import SessionDetails from "./SessionDetails"; import SessionHeader from "./SessionHeader"; +const FRAGMENT = graphql(/* GraphQL */ ` + fragment BrowserSession_detail on BrowserSession { + id + createdAt + finishedAt + userAgent + lastActiveIp + lastActiveAt + lastAuthentication { + id + createdAt + } + user { + id + username + } + } +`); + type Props = { - session: FragmentType; + session: FragmentType; }; const BrowserSessionDetail: React.FC = ({ session }) => { - const data = useFragment(BROWSER_SESSION_DETAIL_FRAGMENT, session); + const data = useFragment(FRAGMENT, session); const currentBrowserSessionId = useCurrentBrowserSessionId(); const isCurrent = currentBrowserSessionId === data.id; @@ -46,18 +66,34 @@ const BrowserSessionDetail: React.FC = ({ session }) => { sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; const finishedAt = data.finishedAt - ? [{ label: "Finished", value: }] + ? [ + { + label: "Finished", + value: , + }, + ] : []; - const ipAddress = data.ipAddress - ? [{ label: "IP Address", value: {data.ipAddress} }] + const lastActiveIp = data.lastActiveIp + ? [{ label: "IP Address", value: {data.lastActiveIp} }] + : []; + + const lastActiveAt = data.lastActiveAt + ? [ + { + label: "Last Active", + value: , + }, + ] : []; const lastAuthentication = data.lastAuthentication ? [ { label: "Last Authentication", - value: , + value: ( + + ), }, ] : []; @@ -68,7 +104,8 @@ const BrowserSessionDetail: React.FC = ({ session }) => { { label: "User Name", value: {data.user.username} }, { label: "Signed in", value: }, ...finishedAt, - ...ipAddress, + ...lastActiveAt, + ...lastActiveIp, ...lastAuthentication, ]; diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx index 0aef0876..aad1b729 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx @@ -17,18 +17,19 @@ import { render, cleanup } from "@testing-library/react"; import { describe, expect, it, afterEach, beforeAll } from "vitest"; -import { makeFragmentData } from "../../gql/fragment-masking"; +import { makeFragmentData } from "../../gql"; import { WithLocation } from "../../test-utils/WithLocation"; import { mockLocale } from "../../test-utils/mockLocale"; -import { COMPAT_SESSION_FRAGMENT } from "../CompatSession"; -import CompatSessionDetail from "./CompatSessionDetail"; +import CompatSessionDetail, { FRAGMENT } from "./CompatSessionDetail"; describe("", () => { const baseSession = { id: "session-id", deviceId: "abcd1234", createdAt: "2023-06-29T03:35:17.451292+00:00", + lastActiveIp: "1.2.3.4", + lastActiveAt: "2023-07-29T03:35:17.451292+00:00", ssoLogin: { id: "test-id", redirectUri: "https://element.io", @@ -39,7 +40,7 @@ describe("", () => { afterEach(cleanup); it("renders a compatability session details", () => { - const data = makeFragmentData(baseSession, COMPAT_SESSION_FRAGMENT); + const data = makeFragmentData(baseSession, FRAGMENT); const { container } = render( @@ -50,16 +51,13 @@ describe("", () => { expect(container).toMatchSnapshot(); }); - it("renders a compatability session without an ssoLogin redirectUri", () => { + it("renders a compatability session without an ssoLogin", () => { const data = makeFragmentData( { ...baseSession, - ssoLogin: { - id: "dfsdjfdk", - redirectUri: undefined, - }, + ssoLogin: null, }, - COMPAT_SESSION_FRAGMENT, + FRAGMENT, ); const { container } = render( @@ -77,7 +75,7 @@ describe("", () => { ...baseSession, finishedAt: "2023-07-29T03:35:17.451292+00:00", }, - COMPAT_SESSION_FRAGMENT, + FRAGMENT, ); const { getByText, queryByText } = render( diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx index d8b1f59c..5d5bced8 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx @@ -12,28 +12,41 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { parseISO } from "date-fns"; import { useSetAtom } from "jotai"; -import { FragmentType, useFragment } from "../../gql"; +import { FragmentType, graphql, useFragment } from "../../gql"; import BlockList from "../BlockList/BlockList"; -import { - COMPAT_SESSION_FRAGMENT, - endCompatSessionFamily, - simplifyUrl, -} from "../CompatSession"; +import { endCompatSessionFamily, simplifyUrl } from "../CompatSession"; import DateTime from "../DateTime"; import ExternalLink from "../ExternalLink/ExternalLink"; import EndSessionButton from "../Session/EndSessionButton"; +import LastActive from "../Session/LastActive"; import SessionDetails from "./SessionDetails"; import SessionHeader from "./SessionHeader"; +export const FRAGMENT = graphql(/* GraphQL */ ` + fragment CompatSession_detail on CompatSession { + id + createdAt + deviceId + finishedAt + lastActiveIp + lastActiveAt + ssoLogin { + id + redirectUri + } + } +`); + type Props = { - session: FragmentType; + session: FragmentType; }; const CompatSessionDetail: React.FC = ({ session }) => { - const data = useFragment(COMPAT_SESSION_FRAGMENT, session); + const data = useFragment(FRAGMENT, session); const endSession = useSetAtom(endCompatSessionFamily(data.id)); const onSessionEnd = async (): Promise => { @@ -41,19 +54,37 @@ const CompatSessionDetail: React.FC = ({ session }) => { }; const finishedAt = data.finishedAt - ? [{ label: "Finished", value: }] + ? [ + { + label: "Finished", + value: , + }, + ] : []; - const ipAddress = data.ipAddress - ? [{ label: "IP Address", value: {data.ipAddress} }] + const lastActiveIp = data.lastActiveIp + ? [{ label: "IP Address", value: {data.lastActiveIp} }] + : []; + + const lastActiveAt = data.lastActiveAt + ? [ + { + label: "Last Active", + value: , + }, + ] : []; const sessionDetails = [ { label: "ID", value: {data.id} }, { label: "Device ID", value: {data.deviceId} }, - { label: "Signed in", value: }, + { + label: "Signed in", + value: , + }, ...finishedAt, - ...ipAddress, + ...lastActiveAt, + ...lastActiveIp, ]; const clientDetails: { label: string; value: string | JSX.Element }[] = []; diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx index 84567f7a..2b964733 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx @@ -17,12 +17,11 @@ import { render, cleanup } from "@testing-library/react"; import { describe, expect, it, afterEach, beforeAll } from "vitest"; -import { makeFragmentData } from "../../gql/fragment-masking"; +import { makeFragmentData } from "../../gql"; import { WithLocation } from "../../test-utils/WithLocation"; import { mockLocale } from "../../test-utils/mockLocale"; -import { OAUTH2_SESSION_FRAGMENT } from "../OAuth2Session"; -import OAuth2SessionDetail from "./OAuth2SessionDetail"; +import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail"; describe("", () => { const baseSession = { @@ -30,6 +29,8 @@ describe("", () => { scope: "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:abcd1234", createdAt: "2023-06-29T03:35:17.451292+00:00", + lastActiveAt: "2023-07-29T03:35:17.451292+00:00", + lastActiveIp: "1.2.3.4", client: { id: "test-id", clientId: "test-client-id", @@ -42,7 +43,7 @@ describe("", () => { afterEach(cleanup); it("renders session details", () => { - const data = makeFragmentData(baseSession, OAUTH2_SESSION_FRAGMENT); + const data = makeFragmentData(baseSession, FRAGMENT); const { container } = render( @@ -59,7 +60,7 @@ describe("", () => { ...baseSession, finishedAt: "2023-07-29T03:35:17.451292+00:00", }, - OAUTH2_SESSION_FRAGMENT, + FRAGMENT, ); const { getByText, queryByText } = render( diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx index 0c7a301c..d36042e4 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx @@ -12,26 +12,46 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { parseISO } from "date-fns"; import { useSetAtom } from "jotai"; -import { FragmentType, useFragment } from "../../gql"; +import { FragmentType, graphql, useFragment } from "../../gql"; import { Link } from "../../routing"; import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope"; import BlockList from "../BlockList/BlockList"; import DateTime from "../DateTime"; -import { OAUTH2_SESSION_FRAGMENT, endSessionFamily } from "../OAuth2Session"; +import { endSessionFamily } from "../OAuth2Session"; import ClientAvatar from "../Session/ClientAvatar"; import EndSessionButton from "../Session/EndSessionButton"; +import LastActive from "../Session/LastActive"; import SessionDetails from "./SessionDetails"; import SessionHeader from "./SessionHeader"; +export const FRAGMENT = graphql(/* GraphQL */ ` + fragment OAuth2Session_detail on Oauth2Session { + id + scope + createdAt + finishedAt + lastActiveIp + lastActiveAt + client { + id + clientId + clientName + clientUri + logoUri + } + } +`); + type Props = { - session: FragmentType; + session: FragmentType; }; const OAuth2SessionDetail: React.FC = ({ session }) => { - const data = useFragment(OAUTH2_SESSION_FRAGMENT, session); + const data = useFragment(FRAGMENT, session); const endSession = useSetAtom(endSessionFamily(data.id)); const onSessionEnd = async (): Promise => { @@ -43,11 +63,25 @@ const OAuth2SessionDetail: React.FC = ({ session }) => { const scopes = data.scope.split(" "); const finishedAt = data.finishedAt - ? [{ label: "Finished", value: }] + ? [ + { + label: "Finished", + value: , + }, + ] : []; - const ipAddress = data.ipAddress - ? [{ label: "IP Address", value: {data.ipAddress} }] + const lastActiveIp = data.lastActiveIp + ? [{ label: "IP Address", value: {data.lastActiveIp} }] + : []; + + const lastActiveAt = data.lastActiveAt + ? [ + { + label: "Last Active", + value: , + }, + ] : []; const sessionDetails = [ @@ -55,15 +89,16 @@ const OAuth2SessionDetail: React.FC = ({ session }) => { { label: "Device ID", value: {deviceId} }, { label: "Signed in", value: }, ...finishedAt, - ...ipAddress, + ...lastActiveAt, + ...lastActiveIp, { label: "Scopes", value: ( -
+ {scopes.map((scope) => ( {scope} ))} -
+ ), }, ]; @@ -89,7 +124,7 @@ const OAuth2SessionDetail: React.FC = ({ session }) => { { label: "Uri", value: ( - + {data.client.clientUri} ), diff --git a/frontend/src/components/SessionDetail/SessionDetail.tsx b/frontend/src/components/SessionDetail/SessionDetail.tsx index c3e3db97..23241242 100644 --- a/frontend/src/components/SessionDetail/SessionDetail.tsx +++ b/frontend/src/components/SessionDetail/SessionDetail.tsx @@ -28,8 +28,8 @@ const QUERY = graphql(/* GraphQL */ ` query SessionQuery($userId: ID!, $deviceId: String!) { session(userId: $userId, deviceId: $deviceId) { __typename - ...CompatSession_session - ...OAuth2Session_session + ...CompatSession_detail + ...OAuth2Session_detail } } `); @@ -45,6 +45,11 @@ const sessionFamily = atomFamily( }, ); +// A type-safe way to ensure we've handled all session types +const unknownSessionType = (type: never): never => { + throw new Error(`Unknown session type: ${type}`); +}; + const SessionDetail: React.FC<{ deviceId: string; userId: string; @@ -70,10 +75,13 @@ const SessionDetail: React.FC<{ const sessionType = session.__typename; - if (sessionType === "Oauth2Session") { - return ; - } else { - return ; + switch (sessionType) { + case "CompatSession": + return ; + case "Oauth2Session": + return ; + default: + unknownSessionType(sessionType); } }; diff --git a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap index c9a641df..80682ad2 100644 --- a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap +++ b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap @@ -94,6 +94,40 @@ exports[` > renders a compatability session details 1`] = `

+
  • +

    + Last Active +

    +

    + + Active Sat, 29 Jul 2023, 03:35 + +

    +
  • +
  • +

    + IP Address +

    +

    + + 1.2.3.4 + +

    +
  • > renders a compatability session details 1`] = `
    `; -exports[` > renders a compatability session without an ssoLogin redirectUri 1`] = ` +exports[` > renders a compatability session without an ssoLogin 1`] = `
    > renders a compatability session without an ssoL

    +
  • +

    + Last Active +

    +

    + + Active Sat, 29 Jul 2023, 03:35 + +

    +
  • +
  • +

    + IP Address +

    +

    + + 1.2.3.4 + +

    +