From 457ade5a607aefe0f33f907f21936b0af4994189 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 22 Apr 2025 13:17:29 +0100 Subject: [PATCH 01/26] WIP support for experimental plan management tab in UI --- crates/cli/src/util.rs | 1 + crates/config/src/sections/experimental.rs | 7 +++ crates/data-model/src/site_config.rs | 3 ++ .../handlers/src/graphql/model/site_config.rs | 3 ++ crates/handlers/src/test_utils.rs | 1 + docs/config.schema.json | 5 ++ frontend/locales/en.json | 1 + frontend/schema.graphql | 1 + frontend/src/gql/gql.ts | 18 +++++-- frontend/src/gql/graphql.ts | 52 ++++++++++++++++++- frontend/src/routeTree.gen.ts | 27 ++++++++++ frontend/src/routes/_account.index.tsx | 7 +++ frontend/src/routes/_account.plan.index.tsx | 44 ++++++++++++++++ frontend/src/routes/_account.tsx | 4 ++ 14 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 frontend/src/routes/_account.plan.index.tsx diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 2daad2e91..041b3f67c 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -215,6 +215,7 @@ pub fn site_config_from_config( minimum_password_complexity: password_config.minimum_complexity(), session_expiration, login_with_email_allowed: account_config.login_with_email_allowed, + plan_management_iframe_uri: experimental_config.plan_management_iframe_uri.clone(), }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 17ffa6c4d..f3bac4071 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -8,6 +8,7 @@ use chrono::Duration; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_with::serde_as; +use url::Url; use crate::ConfigurationSection; @@ -75,6 +76,10 @@ pub struct ExperimentalConfig { /// Disabled by default #[serde(skip_serializing_if = "Option::is_none")] pub inactive_session_expiration: Option, + + /// Experimental feature to show a plan management tab and iframe + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_management_iframe_uri: Option, } impl Default for ExperimentalConfig { @@ -83,6 +88,7 @@ impl Default for ExperimentalConfig { access_token_ttl: default_token_ttl(), compat_token_ttl: default_token_ttl(), inactive_session_expiration: None, + plan_management_iframe_uri: None, } } } @@ -92,6 +98,7 @@ impl ExperimentalConfig { is_default_token_ttl(&self.access_token_ttl) && is_default_token_ttl(&self.compat_token_ttl) && self.inactive_session_expiration.is_none() + && self.plan_management_iframe_uri.is_none() } } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index de07a03c5..2d22b3f06 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -90,4 +90,7 @@ pub struct SiteConfig { /// Whether users can log in with their email address. pub login_with_email_allowed: bool, + + /// The iframe URL to show in the plan tab of the UI + pub plan_management_iframe_uri: Option, } diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs index 02ba26fa2..7df56f0ad 100644 --- a/crates/handlers/src/graphql/model/site_config.rs +++ b/crates/handlers/src/graphql/model/site_config.rs @@ -56,6 +56,8 @@ pub struct SiteConfig { /// Whether users can log in with their email address. login_with_email_allowed: bool, + + plan_management_iframe_uri: Option, } #[derive(SimpleObject)] @@ -102,6 +104,7 @@ impl SiteConfig { account_deactivation_allowed: data_model.account_deactivation_allowed, 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(), } } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index b6d9fba9d..03f36a214 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -142,6 +142,7 @@ pub fn test_site_config() -> SiteConfig { minimum_password_complexity: 1, session_expiration: None, login_with_email_allowed: true, + plan_management_iframe_uri: None, } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 165cf947d..267a03d7e 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2539,6 +2539,11 @@ "$ref": "#/definitions/InactiveSessionExpirationConfig" } ] + }, + "plan_management_iframe_uri": { + "description": "Experimental feature to show a plan management tab and iframe", + "type": "string", + "format": "uri" } } }, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 7f0343e85..bb2fd5a7a 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -122,6 +122,7 @@ }, "nav": { "devices": "Devices", + "plan": "Plan", "settings": "Settings" }, "not_found_alert_title": "Not found.", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 4fdc85332..758e96193 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1665,6 +1665,7 @@ type SiteConfig implements Node { Whether users can log in with their email address. """ loginWithEmailAllowed: Boolean! + planManagementIframeUri: Url """ The ID of the site configuration. """ diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index aeb68252f..7594a91a5 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -48,10 +48,12 @@ type Documents = { "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc, "\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 fragment PlanManagement_siteConfig on SiteConfig {\n planManagementIframeUri\n }\n": typeof types.PlanManagement_SiteConfigFragmentDoc, + "\n query SiteConfig {\n siteConfig {\n ...PlanManagement_siteConfig\n }\n }\n": typeof types.SiteConfigDocument, "\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 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 }\n }\n": typeof types.CurrentUserGreetingDocument, + "\n query CurrentUserGreeting {\n viewer {\n __typename\n ... on User {\n ...UserGreeting_user\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n ...PlanManagement_siteConfig\n }\n }\n": typeof types.CurrentUserGreetingDocument, "\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": typeof types.OAuth2ClientDocument, "\n query CurrentViewer {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n }\n": typeof types.CurrentViewerDocument, "\n query DeviceRedirect($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n": typeof types.DeviceRedirectDocument, @@ -102,10 +104,12 @@ const documents: Documents = { "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc, "\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 fragment PlanManagement_siteConfig on SiteConfig {\n planManagementIframeUri\n }\n": types.PlanManagement_SiteConfigFragmentDoc, + "\n query SiteConfig {\n siteConfig {\n ...PlanManagement_siteConfig\n }\n }\n": types.SiteConfigDocument, "\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 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 }\n }\n": types.CurrentUserGreetingDocument, + "\n query CurrentUserGreeting {\n viewer {\n __typename\n ... on User {\n ...UserGreeting_user\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n ...PlanManagement_siteConfig\n }\n }\n": types.CurrentUserGreetingDocument, "\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": types.OAuth2ClientDocument, "\n query CurrentViewer {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.CurrentViewerDocument, "\n query DeviceRedirect($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.DeviceRedirectDocument, @@ -255,6 +259,14 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us * 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 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 import('./graphql').UserProfileDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment PlanManagement_siteConfig on SiteConfig {\n planManagementIframeUri\n }\n"): typeof import('./graphql').PlanManagement_SiteConfigFragmentDoc; +/** + * 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 SiteConfig {\n siteConfig {\n ...PlanManagement_siteConfig\n }\n }\n"): typeof import('./graphql').SiteConfigDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -270,7 +282,7 @@ export function graphql(source: "\n query AppSessionsList(\n $before: String /** * 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 CurrentUserGreeting {\n viewer {\n __typename\n ... on User {\n ...UserGreeting_user\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n"): typeof import('./graphql').CurrentUserGreetingDocument; +export function graphql(source: "\n query CurrentUserGreeting {\n viewer {\n __typename\n ... on User {\n ...UserGreeting_user\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n ...PlanManagement_siteConfig\n }\n }\n"): typeof import('./graphql').CurrentUserGreetingDocument; /** * 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 6bdb8d33f..e24b510c1 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1232,6 +1232,7 @@ export type SiteConfig = Node & { passwordLoginEnabled: Scalars['Boolean']['output']; /** Whether passwords are enabled and users can register using a password. */ passwordRegistrationEnabled: Scalars['Boolean']['output']; + planManagementIframeUri?: Maybe; /** The URL to the privacy policy. */ policyUri?: Maybe; /** The server name of the homeserver. */ @@ -1778,6 +1779,16 @@ export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typena & { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment;'AccountDeleteButton_SiteConfigFragment': AccountDeleteButton_SiteConfigFragment } } ) }; +export type PlanManagement_SiteConfigFragment = { __typename?: 'SiteConfig', planManagementIframeUri?: string | null } & { ' $fragmentName'?: 'PlanManagement_SiteConfigFragment' }; + +export type SiteConfigQueryVariables = Exact<{ [key: string]: never; }>; + + +export type SiteConfigQuery = { __typename?: 'Query', siteConfig: ( + { __typename?: 'SiteConfig' } + & { ' $fragmentRefs'?: { 'PlanManagement_SiteConfigFragment': PlanManagement_SiteConfigFragment } } + ) }; + export type BrowserSessionListQueryVariables = Exact<{ first?: InputMaybe; after?: InputMaybe; @@ -1825,7 +1836,7 @@ export type CurrentUserGreetingQuery = { __typename?: 'Query', viewer: { __typen & { ' $fragmentRefs'?: { 'UserGreeting_UserFragment': UserGreeting_UserFragment } } ), siteConfig: ( { __typename?: 'SiteConfig' } - & { ' $fragmentRefs'?: { 'UserGreeting_SiteConfigFragment': UserGreeting_SiteConfigFragment } } + & { ' $fragmentRefs'?: { 'UserGreeting_SiteConfigFragment': UserGreeting_SiteConfigFragment;'PlanManagement_SiteConfigFragment': PlanManagement_SiteConfigFragment } } ) }; export type OAuth2ClientQueryVariables = Exact<{ @@ -2293,6 +2304,11 @@ export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"BrowserSessionsOverview_user"}) as unknown as TypedDocumentString; +export const PlanManagement_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment PlanManagement_siteConfig on SiteConfig { + planManagementIframeUri +} + `, {"fragmentName":"PlanManagement_siteConfig"}) as unknown as TypedDocumentString; export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentString(` fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { username @@ -2474,6 +2490,15 @@ fragment UserEmailList_siteConfig on SiteConfig { emailChangeAllowed passwordLoginEnabled }`) as unknown as TypedDocumentString; +export const SiteConfigDocument = new TypedDocumentString(` + query SiteConfig { + siteConfig { + ...PlanManagement_siteConfig + } +} + fragment PlanManagement_siteConfig on SiteConfig { + planManagementIframeUri +}`) as unknown as TypedDocumentString; export const BrowserSessionListDocument = new TypedDocumentString(` query BrowserSessionList($first: Int, $after: String, $last: Int, $before: String, $lastActive: DateFilter) { viewerSession { @@ -2659,6 +2684,7 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(` } siteConfig { ...UserGreeting_siteConfig + ...PlanManagement_siteConfig } } fragment UserGreeting_user on User { @@ -2670,6 +2696,9 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(` } fragment UserGreeting_siteConfig on SiteConfig { displayNameChangeAllowed +} +fragment PlanManagement_siteConfig on SiteConfig { + planManagementIframeUri }`) as unknown as TypedDocumentString; export const OAuth2ClientDocument = new TypedDocumentString(` query OAuth2Client($id: ID!) { @@ -3131,6 +3160,27 @@ export const mockUserProfileQuery = (resolver: GraphQLResponseResolver { + * return HttpResponse.json({ + * data: { siteConfig } + * }) + * }, + * requestOptions + * ) + */ +export const mockSiteConfigQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.query( + 'SiteConfig', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index a7afbbfff..4fa8ad0f4 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as ClientsIdImport } from './routes/clients.$id' import { Route as PasswordRecoveryIndexImport } from './routes/password.recovery.index' import { Route as PasswordChangeIndexImport } from './routes/password.change.index' import { Route as AccountSessionsIndexImport } from './routes/_account.sessions.index' +import { Route as AccountPlanIndexImport } from './routes/_account.plan.index' import { Route as PasswordChangeSuccessImport } from './routes/password.change.success' import { Route as EmailsIdVerifyImport } from './routes/emails.$id.verify' import { Route as EmailsIdInUseImport } from './routes/emails.$id.in-use' @@ -103,6 +104,12 @@ const AccountSessionsIndexRoute = AccountSessionsIndexImport.update({ getParentRoute: () => AccountRoute, } as any) +const AccountPlanIndexRoute = AccountPlanIndexImport.update({ + id: '/plan/', + path: '/plan/', + getParentRoute: () => AccountRoute, +} as any) + const PasswordChangeSuccessRoute = PasswordChangeSuccessImport.update({ id: '/password/change/success', path: '/password/change/success', @@ -222,6 +229,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PasswordChangeSuccessImport parentRoute: typeof rootRoute } + '/_account/plan/': { + id: '/_account/plan/' + path: '/plan' + fullPath: '/plan' + preLoaderRoute: typeof AccountPlanIndexImport + parentRoute: typeof AccountImport + } '/_account/sessions/': { id: '/_account/sessions/' path: '/sessions' @@ -251,12 +265,14 @@ declare module '@tanstack/react-router' { interface AccountRouteChildren { AccountIndexRoute: typeof AccountIndexRoute AccountSessionsBrowsersRoute: typeof AccountSessionsBrowsersRoute + AccountPlanIndexRoute: typeof AccountPlanIndexRoute AccountSessionsIndexRoute: typeof AccountSessionsIndexRoute } const AccountRouteChildren: AccountRouteChildren = { AccountIndexRoute: AccountIndexRoute, AccountSessionsBrowsersRoute: AccountSessionsBrowsersRoute, + AccountPlanIndexRoute: AccountPlanIndexRoute, AccountSessionsIndexRoute: AccountSessionsIndexRoute, } @@ -292,6 +308,7 @@ export interface FileRoutesByFullPath { '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessRoute + '/plan': typeof AccountPlanIndexRoute '/sessions': typeof AccountSessionsIndexRoute '/password/change': typeof PasswordChangeIndexRoute '/password/recovery': typeof PasswordRecoveryIndexRoute @@ -309,6 +326,7 @@ export interface FileRoutesByTo { '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessRoute + '/plan': typeof AccountPlanIndexRoute '/sessions': typeof AccountSessionsIndexRoute '/password/change': typeof PasswordChangeIndexRoute '/password/recovery': typeof PasswordRecoveryIndexRoute @@ -329,6 +347,7 @@ export interface FileRoutesById { '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessRoute + '/_account/plan/': typeof AccountPlanIndexRoute '/_account/sessions/': typeof AccountSessionsIndexRoute '/password/change/': typeof PasswordChangeIndexRoute '/password/recovery/': typeof PasswordRecoveryIndexRoute @@ -350,6 +369,7 @@ export interface FileRouteTypes { | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' + | '/plan' | '/sessions' | '/password/change' | '/password/recovery' @@ -366,6 +386,7 @@ export interface FileRouteTypes { | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' + | '/plan' | '/sessions' | '/password/change' | '/password/recovery' @@ -384,6 +405,7 @@ export interface FileRouteTypes { | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' + | '/_account/plan/' | '/_account/sessions/' | '/password/change/' | '/password/recovery/' @@ -443,6 +465,7 @@ export const routeTree = rootRoute "children": [ "/_account/", "/_account/sessions/browsers", + "/_account/plan/", "/_account/sessions/" ] }, @@ -492,6 +515,10 @@ export const routeTree = rootRoute "/password/change/success": { "filePath": "password.change.success.tsx" }, + "/_account/plan/": { + "filePath": "_account.plan.index.tsx", + "parent": "/_account" + }, "/_account/sessions/": { "filePath": "_account.sessions.index.tsx", "parent": "/_account" diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 5718eee16..169eab0b5 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -83,6 +83,9 @@ const actionSchema = v.variant("action", [ v.object({ action: v.literal("org.matrix.cross_signing_reset"), }), + v.object({ + action: v.literal("org.matrix.plan_management"), + }), v.partial( v.looseObject({ action: v.never(), @@ -126,6 +129,10 @@ export const Route = createFileRoute("/_account/")({ to: "/reset-cross-signing", search: { deepLink: true }, }); + case "org.matrix.plan_management": + throw redirect({ + to: "/plan", + }); } }, diff --git a/frontend/src/routes/_account.plan.index.tsx b/frontend/src/routes/_account.plan.index.tsx new file mode 100644 index 000000000..504e428e7 --- /dev/null +++ b/frontend/src/routes/_account.plan.index.tsx @@ -0,0 +1,44 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { createFileRoute } from "@tanstack/react-router"; +import { graphql } from "../gql"; +import { graphqlRequest } from "../graphql"; +import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; + +export const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` + fragment PlanManagement_siteConfig on SiteConfig { + planManagementIframeUri + } +`); + +const QUERY = graphql(/* GraphQL */ ` + query SiteConfig { + siteConfig { + ...PlanManagement_siteConfig + } + } +`); + +const query = queryOptions({ + queryKey: ["siteConfig"], + queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }), +}); + +export const Route = createFileRoute("/_account/plan/")({ + loader: ({ context }) => context.queryClient.ensureQueryData(query), + component: Plan, +}); + +function Plan(): React.ReactElement { + const siteConfig = result.data.siteConfig; + + return ( +