WIP support for experimental plan management tab in UI

This commit is contained in:
Hugh Nimmo-Smith
2025-04-22 13:17:29 +01:00
parent bb6a6e8081
commit 57cc89a0c8
14 changed files with 170 additions and 4 deletions

View File

@@ -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(),
})
}

View File

@@ -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<InactiveSessionExpirationConfig>,
/// Experimental feature to show a plan management tab and iframe
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_management_iframe_uri: Option<Url>,
}
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()
}
}

View File

@@ -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<Url>,
}

View File

@@ -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<Url>,
}
#[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(),
}
}
}

View File

@@ -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,
}
}

View File

@@ -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"
}
}
},

View File

@@ -122,6 +122,7 @@
},
"nav": {
"devices": "Devices",
"plan": "Plan",
"settings": "Settings"
},
"not_found_alert_title": "Not found.",

View File

@@ -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.
"""

View File

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

View File

@@ -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<Scalars['Url']['output']>;
/** The URL to the privacy policy. */
policyUri?: Maybe<Scalars['Url']['output']>;
/** 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<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
@@ -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<BrowserSessionsOverview_UserFragment, unknown>;
export const PlanManagement_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment PlanManagement_siteConfig on SiteConfig {
planManagementIframeUri
}
`, {"fragmentName":"PlanManagement_siteConfig"}) as unknown as TypedDocumentString<PlanManagement_SiteConfigFragment, unknown>;
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<UserProfileQuery, UserProfileQueryVariables>;
export const SiteConfigDocument = new TypedDocumentString(`
query SiteConfig {
siteConfig {
...PlanManagement_siteConfig
}
}
fragment PlanManagement_siteConfig on SiteConfig {
planManagementIframeUri
}`) as unknown as TypedDocumentString<SiteConfigQuery, SiteConfigQueryVariables>;
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<CurrentUserGreetingQuery, CurrentUserGreetingQueryVariables>;
export const OAuth2ClientDocument = new TypedDocumentString(`
query OAuth2Client($id: ID!) {
@@ -3131,6 +3160,27 @@ export const mockUserProfileQuery = (resolver: GraphQLResponseResolver<UserProfi
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))
* @see https://mswjs.io/docs/basics/response-resolver
* @example
* mockSiteConfigQuery(
* ({ query, variables }) => {
* return HttpResponse.json({
* data: { siteConfig }
* })
* },
* requestOptions
* )
*/
export const mockSiteConfigQuery = (resolver: GraphQLResponseResolver<SiteConfigQuery, SiteConfigQueryVariables>, options?: RequestHandlerOptions) =>
graphql.query<SiteConfigQuery, SiteConfigQueryVariables>(
'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))

View File

@@ -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"

View File

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

View File

@@ -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 (
<iframe
src={siteConfig.planManagementIframeUri}
style={{ height: "calc(100vh - 350px)" }}
/>
);
}

View File

@@ -26,6 +26,7 @@ const QUERY = graphql(/* GraphQL */ `
siteConfig {
...UserGreeting_siteConfig
...PlanManagement_siteConfig
}
}
`);
@@ -60,6 +61,9 @@ function Account(): React.ReactElement {
<NavBar>
<NavItem to="/">{t("frontend.nav.settings")}</NavItem>
<NavItem to="/sessions">{t("frontend.nav.devices")}</NavItem>
{siteConfig.planManagementIframeUri && (
<NavItem to="/plan">{t("frontend.nav.plan")}</NavItem>
)}
</NavBar>
</div>
</div>