From d1edf64dda57a551ba2ba89d4e2000ad500b8676 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 17 Apr 2026 17:49:20 -0500 Subject: [PATCH] Add header showing total number of devices --- docs/config.schema.json | 2 +- frontend/locales/en.json | 4 ++- frontend/src/gql/gql.ts | 6 ++-- frontend/src/gql/graphql.ts | 5 +++- frontend/src/routes/_account.module.css | 1 - .../src/routes/_account.sessions.index.tsx | 28 ++++++++++++++++--- frontend/src/routes/_account.tsx | 5 +++- 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/docs/config.schema.json b/docs/config.schema.json index 042bbafbd..579b6c789 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2898,7 +2898,7 @@ "type": "object", "properties": { "soft_limit": { - "description": "Upon login in interactive contexts (like OAuth 2.0 sessions), if the\n soft limit is reached, it will display a policy violation screen\n (web UI) to remove sessions before creating the new session.\n\n This is not enforced in non-interactive contexts (like the legacy\n compability login API) as there is no opportunity for us to show\n some UI for people remove some sessions. See [`hard_limit`] for\n enforcement on that side.\n\n [`hard_limit`]: Self::hard_limit", + "description": "Upon login in interactive contexts (like OAuth 2.0 sessions), if the\n soft limit is reached, it will display a policy violation screen\n (web UI) to remove sessions before creating the new session.\n\n This is not enforced in non-interactive contexts (like the legacy\n compability login API) as there is no opportunity for us to show\n some UI for people remove some sessions. See [`hard_limit`] for\n enforcement on that side.\n\n This is the limit that is displayed in the UI\n\n [`hard_limit`]: Self::hard_limit", "type": "integer", "format": "uint64", "minimum": 1 diff --git a/frontend/locales/en.json b/frontend/locales/en.json index cd4836d18..687f51ab1 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -293,7 +293,9 @@ "no_active_sessions": { "default": "You are not signed in to any application.", "inactive_90_days": "All your sessions have been active in the last 90 days." - } + }, + "num_devices_filtered_header": "{{filtered_count}} devices match your filter ({{total_count}} total)", + "num_devices_header": "{{num_devices}} devices" }, "verify_email": { "code_expired_alert": { diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 635e117ca..540e083ce 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -52,7 +52,7 @@ type Documents = { "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument, "\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument, "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": typeof types.BrowserSessionListDocument, - "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": typeof types.SessionsOverviewDocument, + "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n\n # Get the total count of active app sessions before any filtering\n unfilteredAppSessions: appSessions(first: 1, state: ACTIVE) {\n totalCount\n }\n }\n }\n }\n": typeof types.SessionsOverviewDocument, "\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": typeof types.AppSessionsListDocument, "\n query CurrentUserGreeting {\n viewer {\n __typename\n ... on User {\n ...UserGreeting_user\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n planManagementIframeUri\n }\n }\n": typeof types.CurrentUserGreetingDocument, "\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": typeof types.OAuth2ClientDocument, @@ -109,7 +109,7 @@ const documents: Documents = { "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument, "\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument, "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument, - "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewDocument, + "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n\n # Get the total count of active app sessions before any filtering\n unfilteredAppSessions: appSessions(first: 1, state: ACTIVE) {\n totalCount\n }\n }\n }\n }\n": types.SessionsOverviewDocument, "\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListDocument, "\n query CurrentUserGreeting {\n viewer {\n __typename\n ... on User {\n ...UserGreeting_user\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n planManagementIframeUri\n }\n }\n": types.CurrentUserGreetingDocument, "\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": types.OAuth2ClientDocument, @@ -280,7 +280,7 @@ export function graphql(source: "\n query BrowserSessionList(\n $first: Int\ /** * 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 SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n"): typeof import('./graphql').SessionsOverviewDocument; +export function graphql(source: "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n\n # Get the total count of active app sessions before any filtering\n unfilteredAppSessions: appSessions(first: 1, state: ACTIVE) {\n totalCount\n }\n }\n }\n }\n"): typeof import('./graphql').SessionsOverviewDocument; /** * 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 583ebfc5d..73432155a 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1896,7 +1896,7 @@ export type SessionsOverviewQueryVariables = Exact<{ [key: string]: never; }>; export type SessionsOverviewQuery = { __typename?: 'Query', viewer: | { __typename: 'Anonymous' } | ( - { __typename: 'User', id: string } + { __typename: 'User', id: string, unfilteredAppSessions: { __typename?: 'AppSessionConnection', totalCount: number } } & { ' $fragmentRefs'?: { 'BrowserSessionsOverview_UserFragment': BrowserSessionsOverview_UserFragment } } ) }; @@ -2705,6 +2705,9 @@ export const SessionsOverviewDocument = new TypedDocumentString(` ... on User { id ...BrowserSessionsOverview_user + unfilteredAppSessions: appSessions(first: 1, state: ACTIVE) { + totalCount + } } } } diff --git a/frontend/src/routes/_account.module.css b/frontend/src/routes/_account.module.css index 693a9afce..5edbfe3ed 100644 --- a/frontend/src/routes/_account.module.css +++ b/frontend/src/routes/_account.module.css @@ -4,7 +4,6 @@ * Please see LICENSE files in the repository root for full details. */ - .nav-bar-error-icon { display: inline-block; diff --git a/frontend/src/routes/_account.sessions.index.tsx b/frontend/src/routes/_account.sessions.index.tsx index 5e9085d75..26d577e5f 100644 --- a/frontend/src/routes/_account.sessions.index.tsx +++ b/frontend/src/routes/_account.sessions.index.tsx @@ -6,7 +6,7 @@ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import { notFound } from "@tanstack/react-router"; -import { H3 } from "@vector-im/compound-web"; +import { H3, H4 } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; import * as v from "valibot"; import { ButtonLink } from "../components/ButtonLink"; @@ -36,6 +36,11 @@ const QUERY = graphql(/* GraphQL */ ` ... on User { id ...BrowserSessionsOverview_user + + # Get the total count of active app sessions before any filtering + unfilteredAppSessions: appSessions(first: 1, state: ACTIVE) { + totalCount + } } } } @@ -136,9 +141,9 @@ function Sessions(): React.ReactElement { const { t } = useTranslation(); const { inactive, pagination } = Route.useLoaderDeps(); const { - data: { viewer }, + data: { viewer: overviewViewer }, } = useSuspenseQuery(query); - if (viewer.__typename !== "User") throw notFound(); + if (overviewViewer.__typename !== "User") throw notFound(); const { data } = useSuspenseQuery(listQuery(pagination, inactive)); if (data.viewer.__typename !== "User") throw notFound(); @@ -153,10 +158,25 @@ function Sessions(): React.ReactElement { // We reverse the list as we are paginating backwards const edges = [...appSessions.edges].reverse(); + // By default, we just show a "X devices" header + let deviceHeaderText = t("frontend.user_sessions_overview.num_devices_header", { + num_devices: appSessions.totalCount, + }); + // But if we're showing a filtered down view, we want to explain how many devices you + // filtered down to and how many total unfilterd devices there are total. + if (overviewViewer.unfilteredAppSessions.totalCount != appSessions.totalCount) { + deviceHeaderText = t("frontend.user_sessions_overview.num_devices_filtered_header", { + filtered_count: appSessions.totalCount, + total_count: overviewViewer.unfilteredAppSessions.totalCount, + }); + } + return (

{t("frontend.user_sessions_overview.heading")}

- + + +

{deviceHeaderText}

{t("frontend.nav.settings")} - {t("frontend.nav.devices")} + + {t("frontend.nav.devices")}{" "} + + {planManagementIframeUri && ( {t("frontend.nav.plan")} )}