Support for experimental plan management tab in UI (#4549)
This commit is contained in:
@@ -223,6 +223,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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,12 @@ 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.
|
||||
/// This value is passed through "as is" to the client without any
|
||||
/// validation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_management_iframe_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ExperimentalConfig {
|
||||
@@ -83,6 +89,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 +99,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,4 +93,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<String>,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ pub struct SiteConfig {
|
||||
|
||||
/// Whether users can log in with their email address.
|
||||
login_with_email_allowed: bool,
|
||||
|
||||
/// Experimental plan management iframe URI.
|
||||
plan_management_iframe_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject)]
|
||||
@@ -102,6 +105,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ pub fn test_site_config() -> SiteConfig {
|
||||
minimum_password_complexity: 1,
|
||||
session_expiration: None,
|
||||
login_with_email_allowed: true,
|
||||
plan_management_iframe_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2567,6 +2567,10 @@
|
||||
"$ref": "#/definitions/InactiveSessionExpirationConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plan_management_iframe_uri": {
|
||||
"description": "Experimental feature to show a plan management tab and iframe. This value is passed through \"as is\" to the client without any validation.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"devices": "Devices",
|
||||
"plan": "Plan",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"not_found_alert_title": "Not found.",
|
||||
|
||||
@@ -1758,6 +1758,10 @@ type SiteConfig implements Node {
|
||||
"""
|
||||
loginWithEmailAllowed: Boolean!
|
||||
"""
|
||||
Experimental plan management iframe URI.
|
||||
"""
|
||||
planManagementIframeUri: String
|
||||
"""
|
||||
The ID of the site configuration.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
@@ -50,10 +50,11 @@ 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 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 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 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,
|
||||
"\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,
|
||||
@@ -106,10 +107,11 @@ 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 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 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 planManagementIframeUri\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,
|
||||
@@ -267,6 +269,10 @@ 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 query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n"): typeof import('./graphql').PlanManagementTabDocument;
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -282,7 +288,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 planManagementIframeUri\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.
|
||||
*/
|
||||
|
||||
@@ -1296,6 +1296,8 @@ export type SiteConfig = Node & {
|
||||
passwordLoginEnabled: Scalars['Boolean']['output'];
|
||||
/** Whether passwords are enabled and users can register using a password. */
|
||||
passwordRegistrationEnabled: Scalars['Boolean']['output'];
|
||||
/** Experimental plan management iframe URI. */
|
||||
planManagementIframeUri?: Maybe<Scalars['String']['output']>;
|
||||
/** The URL to the privacy policy. */
|
||||
policyUri?: Maybe<Scalars['Url']['output']>;
|
||||
/** The server name of the homeserver. */
|
||||
@@ -1858,6 +1860,11 @@ 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 PlanManagementTabQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type PlanManagementTabQuery = { __typename?: 'Query', siteConfig: { __typename?: 'SiteConfig', planManagementIframeUri?: string | null } };
|
||||
|
||||
export type BrowserSessionListQueryVariables = Exact<{
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -1904,7 +1911,7 @@ export type CurrentUserGreetingQuery = { __typename?: 'Query', viewer: { __typen
|
||||
{ __typename: 'User' }
|
||||
& { ' $fragmentRefs'?: { 'UserGreeting_UserFragment': UserGreeting_UserFragment } }
|
||||
), siteConfig: (
|
||||
{ __typename?: 'SiteConfig' }
|
||||
{ __typename?: 'SiteConfig', planManagementIframeUri?: string | null }
|
||||
& { ' $fragmentRefs'?: { 'UserGreeting_SiteConfigFragment': UserGreeting_SiteConfigFragment } }
|
||||
) };
|
||||
|
||||
@@ -2576,6 +2583,13 @@ fragment UserEmailList_siteConfig on SiteConfig {
|
||||
emailChangeAllowed
|
||||
passwordLoginEnabled
|
||||
}`) as unknown as TypedDocumentString<UserProfileQuery, UserProfileQueryVariables>;
|
||||
export const PlanManagementTabDocument = new TypedDocumentString(`
|
||||
query PlanManagementTab {
|
||||
siteConfig {
|
||||
planManagementIframeUri
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<PlanManagementTabQuery, PlanManagementTabQueryVariables>;
|
||||
export const BrowserSessionListDocument = new TypedDocumentString(`
|
||||
query BrowserSessionList($first: Int, $after: String, $last: Int, $before: String, $lastActive: DateFilter) {
|
||||
viewerSession {
|
||||
@@ -2763,6 +2777,7 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(`
|
||||
}
|
||||
siteConfig {
|
||||
...UserGreeting_siteConfig
|
||||
planManagementIframeUri
|
||||
}
|
||||
}
|
||||
fragment UserGreeting_user on User {
|
||||
@@ -3281,6 +3296,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
|
||||
* mockPlanManagementTabQuery(
|
||||
* ({ query, variables }) => {
|
||||
* return HttpResponse.json({
|
||||
* data: { siteConfig }
|
||||
* })
|
||||
* },
|
||||
* requestOptions
|
||||
* )
|
||||
*/
|
||||
export const mockPlanManagementTabQuery = (resolver: GraphQLResponseResolver<PlanManagementTabQuery, PlanManagementTabQueryVariables>, options?: RequestHandlerOptions) =>
|
||||
graphql.query<PlanManagementTabQuery, PlanManagementTabQueryVariables>(
|
||||
'PlanManagementTab',
|
||||
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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,11 @@ export const Route = createFileRoute("/_account/")({
|
||||
to: "/reset-cross-signing",
|
||||
search: { deepLink: true },
|
||||
});
|
||||
case "org.matrix.plan_management": {
|
||||
// We don't both checking if the plan management iframe is actually available and
|
||||
// instead rely on the plan tab handling it.
|
||||
throw redirect({ to: "/plan" });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
115
frontend/src/routes/_account.plan.index.tsx
Normal file
115
frontend/src/routes/_account.plan.index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Navigate, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { preload } from "react-dom";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
|
||||
const QUERY = graphql(/* GraphQL */ `
|
||||
query PlanManagementTab {
|
||||
siteConfig {
|
||||
planManagementIframeUri
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = queryOptions({
|
||||
queryKey: ["planManagementTab"],
|
||||
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_account/plan/")({
|
||||
loader: async ({ context }) => {
|
||||
const {
|
||||
siteConfig: { planManagementIframeUri },
|
||||
} = await context.queryClient.ensureQueryData(query);
|
||||
|
||||
if (!planManagementIframeUri) throw redirect({ to: "/", replace: true });
|
||||
|
||||
preload(planManagementIframeUri, { as: "document" });
|
||||
},
|
||||
component: Plan,
|
||||
});
|
||||
|
||||
function Plan(): React.ReactElement {
|
||||
const result = useSuspenseQuery(query);
|
||||
const { planManagementIframeUri } = result.data.siteConfig;
|
||||
|
||||
if (!planManagementIframeUri) {
|
||||
// Redirect if no iframe URI is configured
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const ref = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Query the size of the iframe content and set the height
|
||||
// This will only work where the iframe is served from the same origin
|
||||
const calculateHeight = useCallback(() => {
|
||||
const iframe = ref.current;
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
const height =
|
||||
iframe.contentWindow?.document.body.parentElement?.scrollHeight;
|
||||
|
||||
if (height) {
|
||||
iframe.height = `${height}px`;
|
||||
} else {
|
||||
iframe.height = "500px";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const observer = useMemo(
|
||||
() =>
|
||||
new MutationObserver((_mutationsList) => {
|
||||
// we calculate the height immediately when the observer is triggered
|
||||
calculateHeight();
|
||||
// then we recalculate the height after a short timeout to allow for any rendering
|
||||
// that doesn't trigger a mutation. e.g. an iframe
|
||||
setTimeout(() => {
|
||||
calculateHeight();
|
||||
}, 1000);
|
||||
// n.b. we don't worry about the timeout happening after the component is unmounted
|
||||
}),
|
||||
[calculateHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = ref.current;
|
||||
if (iframe) {
|
||||
attachObserver(iframe);
|
||||
}
|
||||
// Cleanup observer when the component unmounts
|
||||
return () => observer.disconnect();
|
||||
}, [observer]);
|
||||
|
||||
const attachObserver = (iframe: HTMLIFrameElement) => {
|
||||
const iframeBody = iframe.contentWindow?.document.body;
|
||||
if (!iframeBody) {
|
||||
return;
|
||||
}
|
||||
// calculate the height immediately
|
||||
calculateHeight();
|
||||
// observe future changes to the body of the iframe
|
||||
observer.observe(iframeBody, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe
|
||||
title="iframe" // no proper title as this is experimental feature
|
||||
ref={ref}
|
||||
onLoad={(e) => attachObserver(e.target as HTMLIFrameElement)}
|
||||
src={planManagementIframeUri}
|
||||
scrolling="no"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
|
||||
siteConfig {
|
||||
...UserGreeting_siteConfig
|
||||
planManagementIframeUri
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -45,7 +46,8 @@ function Account(): React.ReactElement {
|
||||
const result = useSuspenseQuery(query);
|
||||
const viewer = result.data.viewer;
|
||||
if (viewer?.__typename !== "User") throw notFound();
|
||||
const siteConfig = result.data.siteConfig;
|
||||
const { siteConfig } = result.data;
|
||||
const { planManagementIframeUri } = siteConfig;
|
||||
|
||||
return (
|
||||
<Layout wide>
|
||||
@@ -60,6 +62,9 @@ function Account(): React.ReactElement {
|
||||
<NavBar>
|
||||
<NavItem to="/">{t("frontend.nav.settings")}</NavItem>
|
||||
<NavItem to="/sessions">{t("frontend.nav.devices")}</NavItem>
|
||||
{planManagementIframeUri && (
|
||||
<NavItem to="/plan">{t("frontend.nav.plan")}</NavItem>
|
||||
)}
|
||||
</NavBar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user