From 009ea8495c74586bf77f9b40f092f4e1351cb66a Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Mon, 11 Sep 2023 16:36:20 +1200 Subject: [PATCH] browser session detail page --- frontend/src/components/BrowserSession.tsx | 38 +++-- .../src/components/BrowserSessionList.tsx | 13 +- .../BrowserSessionDetail.module.css | 21 +++ .../SessionDetail/BrowserSessionDetail.tsx | 86 +++++++++++ frontend/src/gql/gql.ts | 14 +- frontend/src/gql/graphql.ts | 146 +++++++++++++----- frontend/src/pages/BrowserSession.tsx | 36 +++-- 7 files changed, 277 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/SessionDetail/BrowserSessionDetail.module.css create mode 100644 frontend/src/components/SessionDetail/BrowserSessionDetail.tsx diff --git a/frontend/src/components/BrowserSession.tsx b/frontend/src/components/BrowserSession.tsx index 07b112cad..5833a51ac 100644 --- a/frontend/src/components/BrowserSession.tsx +++ b/frontend/src/components/BrowserSession.tsx @@ -15,6 +15,7 @@ import { atom, useSetAtom } from "jotai"; import { atomFamily } from "jotai/utils"; import { atomWithMutation } from "jotai-urql"; +import { useCallback } from "react"; import { currentBrowserSessionIdAtom, currentUserIdAtom } from "../atoms"; import { FragmentType, graphql, useFragment } from "../gql"; @@ -27,7 +28,7 @@ import { import EndSessionButton from "./Session/EndSessionButton"; import Session from "./Session/Session"; -const FRAGMENT = graphql(/* GraphQL */ ` +export const BROWSER_SESSION_FRAGMENT = graphql(/* GraphQL */ ` fragment BrowserSession_session on BrowserSession { id createdAt @@ -52,7 +53,7 @@ const END_SESSION_MUTATION = graphql(/* GraphQL */ ` } `); -const endSessionFamily = atomFamily((id: string) => { +export const endBrowserSessionFamily = atomFamily((id: string) => { const endSession = atomWithMutation(END_SESSION_MUTATION); // A proxy atom which pre-sets the id variable in the mutation @@ -64,22 +65,17 @@ const endSessionFamily = atomFamily((id: string) => { return endSessionAtom; }); -type Props = { - session: FragmentType; - isCurrent: boolean; -}; - -const BrowserSession: React.FC = ({ session, isCurrent }) => { - const data = useFragment(FRAGMENT, session); - const endSession = useSetAtom(endSessionFamily(data.id)); +export const useEndBrowserSession = ( + sessionId: string, + isCurrent: boolean, +): (() => Promise) => { + const endSession = useSetAtom(endBrowserSessionFamily(sessionId)); // Pull those atoms to reset them when the current session is ended const currentUserId = useSetAtom(currentUserIdAtom); const currentBrowserSessionId = useSetAtom(currentBrowserSessionIdAtom); - const createdAt = data.createdAt; - - const onSessionEnd = async (): Promise => { + const onSessionEnd = useCallback(async (): Promise => { await endSession(); if (isCurrent) { currentBrowserSessionId({ @@ -89,8 +85,22 @@ const BrowserSession: React.FC = ({ session, isCurrent }) => { requestPolicy: "network-only", }); } - }; + }, [isCurrent, endSession, currentBrowserSessionId, currentUserId]); + return onSessionEnd; +}; + +type Props = { + session: FragmentType; + isCurrent: boolean; +}; + +const BrowserSession: React.FC = ({ session, isCurrent }) => { + const data = useFragment(BROWSER_SESSION_FRAGMENT, session); + + const onSessionEnd = useEndBrowserSession(data.id, isCurrent); + + const createdAt = data.createdAt; const deviceInformation = parseUserAgent(data.userAgent || undefined); const sessionName = sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; diff --git a/frontend/src/components/BrowserSessionList.tsx b/frontend/src/components/BrowserSessionList.tsx index 99609320d..7a0301117 100644 --- a/frontend/src/components/BrowserSessionList.tsx +++ b/frontend/src/components/BrowserSessionList.tsx @@ -17,7 +17,7 @@ import { atomFamily } from "jotai/utils"; import { atomWithQuery } from "jotai-urql"; import { useTransition } from "react"; -import { currentBrowserSessionIdAtom, mapQueryAtom } from "../atoms"; +import { mapQueryAtom } from "../atoms"; import { graphql } from "../gql"; import { BrowserSessionState, PageInfo } from "../gql/graphql"; import { @@ -27,6 +27,7 @@ import { Pagination, } from "../pagination"; import { isErr, isOk, unwrapErr, unwrapOk } from "../result"; +import { useCurrentBrowserSessionId } from "../utils/session/useCurrentBrowserSessionId"; import BlockList from "./BlockList"; import BrowserSession from "./BrowserSession"; @@ -112,20 +113,20 @@ const paginationFamily = atomFamily((userId: string) => { }); const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => { - const currentSessionIdResult = useAtomValue(currentBrowserSessionIdAtom); + const { currentBrowserSessionId, currentBrowserSessionIdError } = + useCurrentBrowserSessionId(); const [pending, startTransition] = useTransition(); const result = useAtomValue(browserSessionListFamily(userId)); const setPagination = useSetAtom(currentPaginationAtom); const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const [filter, setFilter] = useAtom(filterAtom); - if (isErr(currentSessionIdResult)) - return ; + if (currentBrowserSessionIdError) + return ; if (isErr(result)) return ; const browserSessions = unwrapOk(result); if (browserSessions === null) return <>Failed to load browser sessions; - const currentSessionId = unwrapOk(currentSessionIdResult); const paginate = (pagination: Pagination): void => { startTransition(() => { @@ -165,7 +166,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => { ))} diff --git a/frontend/src/components/SessionDetail/BrowserSessionDetail.module.css b/frontend/src/components/SessionDetail/BrowserSessionDetail.module.css new file mode 100644 index 000000000..6706f66ae --- /dev/null +++ b/frontend/src/components/SessionDetail/BrowserSessionDetail.module.css @@ -0,0 +1,21 @@ +/* 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. + */ + + .header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--cpd-space-1x); + } \ No newline at end of file diff --git a/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx b/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx new file mode 100644 index 000000000..86f6a22a1 --- /dev/null +++ b/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx @@ -0,0 +1,86 @@ +// 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 { H3, Badge } from "@vector-im/compound-web"; + +import { FragmentType, useFragment } from "../../gql"; +import { BROWSER_SESSION_DETAIL_FRAGMENT } from "../../pages/BrowserSession"; +import { + parseUserAgent, + sessionNameFromDeviceInformation, +} from "../../utils/parseUserAgent"; +import { useCurrentBrowserSessionId } from "../../utils/session/useCurrentBrowserSessionId"; +import BlockList from "../BlockList/BlockList"; +import { useEndBrowserSession } from "../BrowserSession"; +import DateTime from "../DateTime"; +import GraphQLError from "../GraphQLError"; +import EndSessionButton from "../Session/EndSessionButton"; + +import styles from "./BrowserSessionDetail.module.css"; +import SessionDetails from "./SessionDetails"; + +type Props = { + session: FragmentType; +}; + +const BrowserSessionDetail: React.FC = ({ session }) => { + const data = useFragment(BROWSER_SESSION_DETAIL_FRAGMENT, session); + const { currentBrowserSessionId, currentBrowserSessionIdError } = + useCurrentBrowserSessionId(); + + const isCurrent = currentBrowserSessionId === data.id; + const onSessionEnd = useEndBrowserSession(data.id, isCurrent); + + if (currentBrowserSessionIdError) + return ; + + const deviceInformation = parseUserAgent(data.userAgent || undefined); + const sessionName = + sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; + + const finishedAt = data.finishedAt + ? [{ label: "Finished", value: }] + : []; + + const latestAuthentication = data.lastAuthentication + ? [ + { + label: "Last Authentication", + value: , + }, + ] + : []; + + const sessionDetails = [ + { label: "ID", value: {data.id} }, + { label: "User ID", value: {data.user.id} }, + { label: "User Name", value: {data.user.username} }, + { label: "Signed in", value: }, + ...finishedAt, + ...latestAuthentication, + ]; + + return ( + +
+ {isCurrent && Current} +

{sessionName}

+
+ + {!data.finishedAt && } +
+ ); +}; + +export default BrowserSessionDetail; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 75347948c..a9c8748df 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -65,7 +65,9 @@ const documents = { types.VerifyEmailDocument, "\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.ResendVerificationEmailDocument, - "\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n": + "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": + types.BrowserSession_DetailFragmentDoc, + "\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n ...BrowserSession_detail\n }\n }\n": types.BrowserSessionQueryDocument, "\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": types.OAuth2ClientQueryDocument, @@ -249,8 +251,14 @@ 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 query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n", -): (typeof documents)["\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n"]; + source: "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n", +): (typeof documents)["\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: "\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n ...BrowserSession_detail\n }\n }\n", +): (typeof documents)["\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n ...BrowserSession_detail\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 b00f8d4e7..9bc97ff87 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1516,23 +1516,33 @@ export type ResendVerificationEmailMutation = { }; }; +export type BrowserSession_DetailFragment = { + __typename?: "BrowserSession"; + id: string; + createdAt: any; + finishedAt?: any | null; + userAgent?: string | null; + lastAuthentication?: { + __typename?: "Authentication"; + id: string; + createdAt: any; + } | null; + user: { __typename?: "User"; id: string; username: string }; +} & { " $fragmentName"?: "BrowserSession_DetailFragment" }; + export type BrowserSessionQueryQueryVariables = Exact<{ id: Scalars["ID"]["input"]; }>; export type BrowserSessionQueryQuery = { __typename?: "Query"; - browserSession?: { - __typename?: "BrowserSession"; - id: string; - createdAt: any; - lastAuthentication?: { - __typename?: "Authentication"; - id: string; - createdAt: any; - } | null; - user: { __typename?: "User"; id: string; username: string }; - } | null; + browserSession?: + | ({ __typename?: "BrowserSession"; id: string } & { + " $fragmentRefs"?: { + BrowserSession_DetailFragment: BrowserSession_DetailFragment; + }; + }) + | null; }; export type OAuth2ClientQueryQueryVariables = Exact<{ @@ -1929,6 +1939,50 @@ export const UserEmail_VerifyEmailFragmentDoc = { }, ], } as unknown as DocumentNode; +export const BrowserSession_DetailFragmentDoc = { + kind: "Document", + definitions: [ + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "BrowserSession_detail" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "BrowserSession" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "createdAt" } }, + { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, + { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "lastAuthentication" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "createdAt" } }, + ], + }, + }, + { + kind: "Field", + name: { kind: "Name", value: "user" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "username" } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const CurrentViewerQueryDocument = { kind: "Document", definitions: [ @@ -4152,39 +4206,53 @@ export const BrowserSessionQueryDocument = { }, }, ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { + kind: "FragmentSpread", + name: { kind: "Name", value: "BrowserSession_detail" }, + }, + ], + }, + }, + ], + }, + }, + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "BrowserSession_detail" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "BrowserSession" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "createdAt" } }, + { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, + { kind: "Field", name: { kind: "Name", value: "userAgent" } }, + { + kind: "Field", + name: { kind: "Name", value: "lastAuthentication" }, selectionSet: { kind: "SelectionSet", selections: [ { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } }, - { - kind: "Field", - name: { kind: "Name", value: "lastAuthentication" }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { kind: "Field", name: { kind: "Name", value: "id" } }, - { - kind: "Field", - name: { kind: "Name", value: "createdAt" }, - }, - ], - }, - }, - { - kind: "Field", - name: { kind: "Name", value: "user" }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { kind: "Field", name: { kind: "Name", value: "id" } }, - { - kind: "Field", - name: { kind: "Name", value: "username" }, - }, - ], - }, - }, + ], + }, + }, + { + kind: "Field", + name: { kind: "Name", value: "user" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "username" } }, ], }, }, diff --git a/frontend/src/pages/BrowserSession.tsx b/frontend/src/pages/BrowserSession.tsx index 2b1a958e4..1d7e94c16 100644 --- a/frontend/src/pages/BrowserSession.tsx +++ b/frontend/src/pages/BrowserSession.tsx @@ -19,22 +19,32 @@ import { atomWithQuery } from "jotai-urql"; import { mapQueryAtom } from "../atoms"; import GraphQLError from "../components/GraphQLError"; import NotFound from "../components/NotFound"; +import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail"; import { graphql } from "../gql"; import { isErr, unwrapErr, unwrapOk } from "../result"; +export const BROWSER_SESSION_DETAIL_FRAGMENT = graphql(/* GraphQL */ ` + fragment BrowserSession_detail on BrowserSession { + id + createdAt + finishedAt + userAgent + lastAuthentication { + id + createdAt + } + user { + id + username + } + } +`); + const QUERY = graphql(/* GraphQL */ ` query BrowserSessionQuery($id: ID!) { browserSession(id: $id) { id - createdAt - lastAuthentication { - id - createdAt - } - user { - id - username - } + ...BrowserSession_detail } } `); @@ -58,13 +68,9 @@ const BrowserSession: React.FC<{ id: string }> = ({ id }) => { if (isErr(result)) return ; const browserSession = unwrapOk(result); - if (browserSession === null) return ; + if (!browserSession) return ; - return ( -
-      {JSON.stringify(browserSession, null, 2)}
-    
- ); + return ; }; export default BrowserSession;