diff --git a/frontend/src/components/UserSessionsOverview/AppSessionsList.tsx b/frontend/src/components/UserSessionsOverview/AppSessionsList.tsx new file mode 100644 index 00000000..40d53586 --- /dev/null +++ b/frontend/src/components/UserSessionsOverview/AppSessionsList.tsx @@ -0,0 +1,139 @@ +// 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 { H6, Text } from "@vector-im/compound-web"; +import { atom, useAtomValue, useSetAtom } from "jotai"; +import { atomFamily } from "jotai/utils"; +import { atomWithQuery } from "jotai-urql"; +import { useTransition } from "react"; + +import { mapQueryAtom } from "../../atoms"; +import { graphql } from "../../gql"; +import { SessionState, PageInfo } from "../../gql/graphql"; +import { + atomForCurrentPagination, + atomWithPagination, + Pagination, +} from "../../pagination"; +import { isOk, unwrap, unwrapOk } from "../../result"; +import BlockList from "../BlockList"; +import PaginationControls from "../PaginationControls"; + +const QUERY = graphql(/* GraphQL */ ` + query AppSessionList( + $userId: ID! + $state: SessionState + $first: Int + $after: String + $last: Int + $before: String + ) { + user(id: $userId) { + id + appSessions( + first: $first + after: $after + last: $last + before: $before + state: $state + ) { + totalCount + + edges { + cursor + node { + ...CompatSession_session + ...OAuth2Session_session + } + } + + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + } +`); + +const filterAtom = atom(SessionState.Active); +const currentPaginationAtom = atomForCurrentPagination(); + +const appSessionListFamily = atomFamily((userId: string) => { + const appSessionListQuery = atomWithQuery({ + query: QUERY, + getVariables: (get) => ({ + userId, + state: get(filterAtom), + ...get(currentPaginationAtom), + }), + }); + + const appSessionList = mapQueryAtom( + appSessionListQuery, + (data) => data.user?.appSessions || null, + ); + + return appSessionList; +}); + +const pageInfoFamily = atomFamily((userId: string) => { + const pageInfoAtom = atom(async (get): Promise => { + const result = await get(appSessionListFamily(userId)); + return (isOk(result) && unwrapOk(result)?.pageInfo) || null; + }); + return pageInfoAtom; +}); + +const paginationFamily = atomFamily((userId: string) => { + const paginationAtom = atomWithPagination( + currentPaginationAtom, + pageInfoFamily(userId), + ); + + return paginationAtom; +}); + +const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => { + const [pending, startTransition] = useTransition(); + const result = useAtomValue(appSessionListFamily(userId)); + const setPagination = useSetAtom(currentPaginationAtom); + const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); + + const appSessions = unwrap(result); + if (!appSessions) return <>Failed to load app sessions; + + const paginate = (pagination: Pagination): void => { + startTransition(() => { + setPagination(pagination); + }); + }; + + return ( + +
Apps
+ {`${appSessions.totalCount} active sessions`} + paginate(prevPage) : null} + onNext={nextPage ? (): void => paginate(nextPage) : null} + count={appSessions.totalCount} + disabled={pending} + /> +
+ ); +}; + +export default AppSessionsList; diff --git a/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx b/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx index 4a3bfab4..8ef2ec07 100644 --- a/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx +++ b/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx @@ -19,6 +19,7 @@ import { Link } from "../../routing"; import Block from "../Block"; import BlockList from "../BlockList"; +import AppSessionsList from "./AppSessionsList"; import styles from "./UserSessionsOverview.module.css"; export const FRAGMENT = graphql(/* GraphQL */ ` @@ -78,30 +79,24 @@ const UserSessionsOverview: React.FC<{ View all - -
-
New apps
- - {data.oauth2Sessions.totalCount} active{" "} - {pluraliseSession(data.oauth2Sessions.totalCount)} - -
- - View all - -
- -
-
Regular apps
+
+
compatSessions
{data.compatSessions.totalCount} active{" "} {pluraliseSession(data.compatSessions.totalCount)}
- +
+
oauth2Sessions
+ + {data.oauth2Sessions.totalCount} active{" "} + {pluraliseSession(data.oauth2Sessions.totalCount)} + + View all - +
+ ); }; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 91df5f36..e0b4b800 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -57,6 +57,8 @@ const documents = { types.UserPrimaryEmailDocument, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n user {\n id\n matrix {\n displayName\n }\n }\n }\n }\n": types.SetDisplayNameDocument, + "\n query AppSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n appSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": + types.AppSessionListDocument, "\n fragment UserSessionsOverview_user on User {\n id\n\n primaryEmail {\n id\n ...UserEmail_email\n }\n\n confirmedEmails: emails(first: 0, state: CONFIRMED) {\n totalCount\n }\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n\n oauth2Sessions(first: 0, state: ACTIVE) {\n totalCount\n }\n\n compatSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.UserSessionsOverview_UserFragmentDoc, "\n fragment UserEmail_verifyEmail on UserEmail {\n id\n email\n }\n": @@ -223,6 +225,12 @@ export function graphql( export function graphql( source: "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n user {\n id\n matrix {\n displayName\n }\n }\n }\n }\n", ): (typeof documents)["\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n user {\n id\n matrix {\n displayName\n }\n }\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 AppSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n appSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n", +): (typeof documents)["\n query AppSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n appSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\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 c3a4ab04..a140bd0b 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1475,6 +1475,49 @@ export type SetDisplayNameMutation = { }; }; +export type AppSessionListQueryVariables = Exact<{ + userId: Scalars["ID"]["input"]; + state?: InputMaybe; + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; +}>; + +export type AppSessionListQuery = { + __typename?: "Query"; + user?: { + __typename?: "User"; + id: string; + appSessions: { + __typename?: "AppSessionConnection"; + totalCount: number; + edges: Array<{ + __typename?: "AppSessionEdge"; + cursor: string; + node: + | ({ __typename?: "CompatSession" } & { + " $fragmentRefs"?: { + CompatSession_SessionFragment: CompatSession_SessionFragment; + }; + }) + | ({ __typename?: "Oauth2Session" } & { + " $fragmentRefs"?: { + OAuth2Session_SessionFragment: OAuth2Session_SessionFragment; + }; + }); + }>; + pageInfo: { + __typename?: "PageInfo"; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; + endCursor?: string | null; + }; + }; + } | null; +}; + export type UserSessionsOverview_UserFragment = { __typename?: "User"; id: string; @@ -3947,6 +3990,269 @@ export const SetDisplayNameDocument = { SetDisplayNameMutation, SetDisplayNameMutationVariables >; +export const AppSessionListDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "query", + name: { kind: "Name", value: "AppSessionList" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "userId" }, + }, + type: { + kind: "NonNullType", + type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, + }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "state" }, + }, + type: { + kind: "NamedType", + name: { kind: "Name", value: "SessionState" }, + }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "after" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "String" } }, + }, + { + kind: "VariableDefinition", + variable: { kind: "Variable", name: { kind: "Name", value: "last" } }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "before" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "String" } }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "user" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "id" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "userId" }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { + kind: "Field", + name: { kind: "Name", value: "appSessions" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "first" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "after" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "after" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "last" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "last" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "before" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "before" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "state" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "state" }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "totalCount" }, + }, + { + kind: "Field", + name: { kind: "Name", value: "edges" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "cursor" }, + }, + { + kind: "Field", + name: { kind: "Name", value: "node" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "FragmentSpread", + name: { + kind: "Name", + value: "CompatSession_session", + }, + }, + { + kind: "FragmentSpread", + name: { + kind: "Name", + value: "OAuth2Session_session", + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: "Field", + name: { kind: "Name", value: "pageInfo" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "hasNextPage" }, + }, + { + kind: "Field", + name: { kind: "Name", value: "hasPreviousPage" }, + }, + { + kind: "Field", + name: { kind: "Name", value: "startCursor" }, + }, + { + kind: "Field", + name: { kind: "Name", value: "endCursor" }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "CompatSession_session" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "CompatSession" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "createdAt" } }, + { kind: "Field", name: { kind: "Name", value: "deviceId" } }, + { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "ssoLogin" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "redirectUri" } }, + ], + }, + }, + ], + }, + }, + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "OAuth2Session_session" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "Oauth2Session" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "scope" } }, + { kind: "Field", name: { kind: "Name", value: "createdAt" } }, + { kind: "Field", name: { kind: "Name", value: "finishedAt" } }, + { + kind: "Field", + name: { kind: "Name", value: "client" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { 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: "clientUri" } }, + { kind: "Field", name: { kind: "Name", value: "logoUri" } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const VerifyEmailDocument = { kind: "Document", definitions: [