1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-11-23 11:02:35 +03:00

browser session detail page

This commit is contained in:
Kerry Archibald
2023-09-11 16:36:20 +12:00
committed by Quentin Gliech
parent 0d5a700182
commit de04b1679c
7 changed files with 277 additions and 77 deletions

View File

@@ -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<typeof FRAGMENT>;
isCurrent: boolean;
};
const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
const data = useFragment(FRAGMENT, session);
const endSession = useSetAtom(endSessionFamily(data.id));
export const useEndBrowserSession = (
sessionId: string,
isCurrent: boolean,
): (() => Promise<void>) => {
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<void> => {
const onSessionEnd = useCallback(async (): Promise<void> => {
await endSession();
if (isCurrent) {
currentBrowserSessionId({
@@ -89,8 +85,22 @@ const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
requestPolicy: "network-only",
});
}
};
}, [isCurrent, endSession, currentBrowserSessionId, currentUserId]);
return onSessionEnd;
};
type Props = {
session: FragmentType<typeof BROWSER_SESSION_FRAGMENT>;
isCurrent: boolean;
};
const BrowserSession: React.FC<Props> = ({ 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";

View File

@@ -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 <GraphQLError error={unwrapErr(currentSessionIdResult)} />;
if (currentBrowserSessionIdError)
return <GraphQLError error={currentBrowserSessionIdError} />;
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
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 }) => {
<BrowserSession
key={n.cursor}
session={n.node}
isCurrent={n.node.id === currentSessionId}
isCurrent={n.node.id === currentBrowserSessionId}
/>
))}
</BlockList>

View File

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

View File

@@ -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<typeof BROWSER_SESSION_DETAIL_FRAGMENT>;
};
const BrowserSessionDetail: React.FC<Props> = ({ 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 <GraphQLError error={currentBrowserSessionIdError} />;
const deviceInformation = parseUserAgent(data.userAgent || undefined);
const sessionName =
sessionNameFromDeviceInformation(deviceInformation) || "Browser session";
const finishedAt = data.finishedAt
? [{ label: "Finished", value: <DateTime datetime={data.finishedAt} /> }]
: [];
const latestAuthentication = data.lastAuthentication
? [
{
label: "Last Authentication",
value: <DateTime datetime={data.lastAuthentication.createdAt} />,
},
]
: [];
const sessionDetails = [
{ label: "ID", value: <code>{data.id}</code> },
{ label: "User ID", value: <code>{data.user.id}</code> },
{ label: "User Name", value: <code>{data.user.username}</code> },
{ label: "Signed in", value: <DateTime datetime={data.createdAt} /> },
...finishedAt,
...latestAuthentication,
];
return (
<BlockList>
<header className={styles.header}>
{isCurrent && <Badge kind="success">Current</Badge>}
<H3>{sessionName}</H3>
</header>
<SessionDetails title="Session" details={sessionDetails} />
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</BlockList>
);
};
export default BrowserSessionDetail;

View File

@@ -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.
*/

View File

@@ -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<UserEmail_VerifyEmailFragment, unknown>;
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<BrowserSession_DetailFragment, unknown>;
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" } },
],
},
},

View File

@@ -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 <GraphQLError error={unwrapErr(result)} />;
const browserSession = unwrapOk(result);
if (browserSession === null) return <NotFound />;
if (!browserSession) return <NotFound />;
return (
<pre>
<code>{JSON.stringify(browserSession, null, 2)}</code>
</pre>
);
return <BrowserSessionDetail session={browserSession} />;
};
export default BrowserSession;