Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
457ade5
WIP support for experimental plan management tab in UI
hughns Apr 22, 2025
70838ef
Fixes
hughns Apr 22, 2025
a173370
Format
hughns Apr 22, 2025
0f82979
Set the iframe height based on content height
hughns Apr 22, 2025
074f8ff
No need for the action as can link direct to /account/plan
hughns Apr 23, 2025
96f1b59
Revert "No need for the action as can link direct to /account/plan"
hughns May 9, 2025
0caf325
Default height value
hughns May 9, 2025
b79cc6f
Merge remote-tracking branch 'refs/remotes/origin/hughns/plan-managem…
hughns May 9, 2025
25de9bc
Merge branch 'main' into hughns/plan-management
hughns May 9, 2025
c6a3fc5
Lint
hughns May 9, 2025
5a71d49
Lint
hughns May 9, 2025
26af568
Tidy up
hughns May 9, 2025
45ab34f
Redirect to / if no URI configured
hughns May 30, 2025
be01f11
Merge remote-tracking branch 'refs/remotes/origin/hughns/plan-managem…
hughns May 30, 2025
2e90b23
Use <Navigate> component insert of redirect()
hughns Jun 2, 2025
8ef1277
Do fallback redirect at router level
hughns Jun 2, 2025
d4882b0
Review feedback
hughns Jun 4, 2025
34defa7
Handle unloading
hughns Jun 4, 2025
ce7bd5f
Get mutation observer working
hughns Jun 5, 2025
58aa499
Iterate
hughns Jun 5, 2025
b49a477
Update frontend/src/routes/_account.plan.index.tsx
hughns Jun 6, 2025
18d30b3
Lint
hughns Jun 6, 2025
d6dd647
Make plan_management_iframe_uri be a String not URL
hughns Jun 6, 2025
4863920
Lint
hughns Jun 6, 2025
037d90b
Update schemas
hughns Jun 6, 2025
264988f
Revert unnecessary change
hughns Jun 6, 2025
9460f5d
GraphQL model docstring
hughns Jun 6, 2025
b10607e
Lint
hughns Jun 6, 2025
55f559e
Schema update
hughns Jun 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,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(),
})
}

Expand Down
8 changes: 8 additions & 0 deletions crates/config/src/sections/experimental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand All @@ -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()
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/data-model/src/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}
4 changes: 4 additions & 0 deletions crates/handlers/src/graphql/model/site_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/handlers/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ pub fn test_site_config() -> SiteConfig {
minimum_password_complexity: 1,
session_expiration: None,
login_with_email_allowed: true,
plan_management_iframe_uri: None,
}
}

Expand Down
4 changes: 4 additions & 0 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2557,6 +2557,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"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
},
"nav": {
"devices": "Devices",
"plan": "Plan",
"settings": "Settings"
},
"not_found_alert_title": "Not found.",
Expand Down
4 changes: 4 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand Down
38 changes: 37 additions & 1 deletion frontend/src/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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']>;
Expand Down Expand Up @@ -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 } }
) };

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2763,6 +2777,7 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(`
}
siteConfig {
...UserGreeting_siteConfig
planManagementIframeUri
}
}
fragment UserGreeting_user on User {
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading