From 6dce03e27d5fa647acd324ed5a42c3fc249da3b6 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 17 Apr 2026 18:22:32 -0500 Subject: [PATCH] Pipe `session_limit` to frontend --- .../handlers/src/graphql/model/site_config.rs | 26 ++++++++++++ frontend/schema.graphql | 10 +++++ frontend/src/gql/gql.ts | 6 +-- frontend/src/gql/graphql.ts | 18 +++++++- .../src/routes/_account.sessions.index.tsx | 42 +++++++++++++++---- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs index d6966907e..4d9fced37 100644 --- a/crates/handlers/src/graphql/model/site_config.rs +++ b/crates/handlers/src/graphql/model/site_config.rs @@ -59,6 +59,9 @@ pub struct SiteConfig { /// Experimental plan management iframe URI. plan_management_iframe_uri: Option, + + /// Limits on the number of application sessions that each user can have + session_limit: Option, } #[derive(SimpleObject)] @@ -79,6 +82,25 @@ pub enum CaptchaService { HCaptcha, } +#[derive(SimpleObject)] +pub struct SessionLimitConfig { + pub soft_limit: u64, + pub hard_limit: u64, + pub hard_limit_eviction: bool, +} + +impl SessionLimitConfig { + /// Create a new [`SessionLimitConfig`] from the data model + /// [`mas_data_model::SessionLimitConfig`]. + pub fn new(data_model: &mas_data_model::SessionLimitConfig) -> Self { + Self { + soft_limit: data_model.soft_limit.get(), + hard_limit: data_model.hard_limit.get(), + hard_limit_eviction: data_model.hard_limit_eviction, + } + } +} + #[ComplexObject] impl SiteConfig { /// The ID of the site configuration. @@ -106,6 +128,10 @@ impl SiteConfig { minimum_password_complexity: data_model.minimum_password_complexity, login_with_email_allowed: data_model.login_with_email_allowed, plan_management_iframe_uri: data_model.plan_management_iframe_uri.clone(), + session_limit: data_model + .session_limit + .as_ref() + .map(SessionLimitConfig::new), } } } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 99da32010..9fccc7824 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1402,6 +1402,12 @@ A client session, either compat or OAuth 2.0 """ union Session = CompatSession | Oauth2Session +type SessionLimitConfig { + softLimit: Int! + hardLimit: Int! + hardLimitEviction: Boolean! +} + """ The state of a session """ @@ -1762,6 +1768,10 @@ type SiteConfig implements Node { """ planManagementIframeUri: String """ + Limits on the number of application sessions that each user can have + """ + sessionLimit: SessionLimitConfig + """ The ID of the site configuration. """ id: ID! diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 540e083ce..8d46d5541 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 # 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 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 siteConfig {\n sessionLimit {\n softLimit\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 # 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 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 siteConfig {\n sessionLimit {\n softLimit\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 # 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; +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 siteConfig {\n sessionLimit {\n softLimit\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 73432155a..859ed9d69 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1078,6 +1078,13 @@ export type ResendRecoveryEmailStatus = /** A client session, either compat or OAuth 2.0 */ export type Session = CompatSession | Oauth2Session; +export type SessionLimitConfig = { + __typename?: 'SessionLimitConfig'; + hardLimit: Scalars['Int']['output']; + hardLimitEviction: Scalars['Boolean']['output']; + softLimit: Scalars['Int']['output']; +}; + /** The state of a session */ export type SessionState = /** The session is active. */ @@ -1302,6 +1309,8 @@ export type SiteConfig = Node & { policyUri?: Maybe; /** The server name of the homeserver. */ serverName: Scalars['String']['output']; + /** Limits on the number of application sessions that each user can have */ + sessionLimit?: Maybe; /** The URL to the terms of service. */ tosUri?: Maybe; }; @@ -1899,7 +1908,7 @@ export type SessionsOverviewQuery = { __typename?: 'Query', viewer: { __typename: 'User', id: string, unfilteredAppSessions: { __typename?: 'AppSessionConnection', totalCount: number } } & { ' $fragmentRefs'?: { 'BrowserSessionsOverview_UserFragment': BrowserSessionsOverview_UserFragment } } ) - }; + , siteConfig: { __typename?: 'SiteConfig', sessionLimit?: { __typename?: 'SessionLimitConfig', softLimit: number } | null } }; export type AppSessionsListQueryVariables = Exact<{ before?: InputMaybe; @@ -2710,6 +2719,11 @@ export const SessionsOverviewDocument = new TypedDocumentString(` } } } + siteConfig { + sessionLimit { + softLimit + } + } } fragment BrowserSessionsOverview_user on User { id @@ -3401,7 +3415,7 @@ export const mockBrowserSessionListQuery = (resolver: GraphQLResponseResolver
{ * return HttpResponse.json({ - * data: { viewer } + * data: { viewer, siteConfig } * }) * }, * requestOptions diff --git a/frontend/src/routes/_account.sessions.index.tsx b/frontend/src/routes/_account.sessions.index.tsx index 26d577e5f..e9863db63 100644 --- a/frontend/src/routes/_account.sessions.index.tsx +++ b/frontend/src/routes/_account.sessions.index.tsx @@ -43,6 +43,12 @@ const QUERY = graphql(/* GraphQL */ ` } } } + + siteConfig { + sessionLimit { + softLimit + } + } } `); @@ -141,9 +147,10 @@ function Sessions(): React.ReactElement { const { t } = useTranslation(); const { inactive, pagination } = Route.useLoaderDeps(); const { - data: { viewer: overviewViewer }, + data: { viewer: overviewViewer, siteConfig }, } = useSuspenseQuery(query); if (overviewViewer.__typename !== "User") throw notFound(); + const { sessionLimit } = siteConfig; const { data } = useSuspenseQuery(listQuery(pagination, inactive)); if (data.viewer.__typename !== "User") throw notFound(); @@ -159,16 +166,32 @@ function Sessions(): React.ReactElement { 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, - }); + 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, - }); + 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, + }, + ); + } + + // TODO + let todoSessionLimit = null; + if (sessionLimit) { + todoSessionLimit = ( +
TODO: Limited to {sessionLimit.softLimit} devices
+ ); } return ( @@ -177,6 +200,7 @@ function Sessions(): React.ReactElement {

{deviceHeaderText}

+ {todoSessionLimit}