diff --git a/.gitignore b/.gitignore index 2f7896d1d..2e365f28d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ target/ +config.yaml \ No newline at end of file diff --git a/crates/handlers/src/graphql/model/upstream_oauth.rs b/crates/handlers/src/graphql/model/upstream_oauth.rs index 85013a916..888f4edea 100644 --- a/crates/handlers/src/graphql/model/upstream_oauth.rs +++ b/crates/handlers/src/graphql/model/upstream_oauth.rs @@ -7,7 +7,11 @@ use anyhow::Context as _; use async_graphql::{Context, Object, ID}; use chrono::{DateTime, Utc}; -use mas_storage::{upstream_oauth2::UpstreamOAuthProviderRepository, user::UserRepository}; +use mas_storage::{ + upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthProviderRepository}, + user::UserRepository, + Pagination, +}; use super::{NodeType, User}; use crate::graphql::state::ContextExt; @@ -45,6 +49,57 @@ impl UpstreamOAuth2Provider { pub async fn client_id(&self) -> &str { &self.provider.client_id } + + /// The human-readable name of the provider. + pub async fn human_name(&self) -> Option<&str> { + self.provider.human_name.as_deref() + } + + /// The brand name of the provider. + /// + /// Values supported by the default template are: + /// + /// - `apple` + /// - `google` + /// - `facebook` + /// - `github` + /// - `gitlab` + /// - `twitter` + /// + /// Note that this is a free-form field and can be any string value. + pub async fn brand_name(&self) -> Option<&str> { + self.provider.brand_name.as_deref() + } + + /// UpstreamOAuth2Links associated with this provider for the current user. + pub async fn upstream_oauth2_links_for_user( + &self, + ctx: &Context<'_>, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let user = ctx + .requester() + .user() + .ok_or_else(|| async_graphql::Error::new("User ID not found in the request context"))?; + + let mut repo = state.repository().await?; + let filter = UpstreamOAuthLinkFilter::new() + .for_provider(&self.provider) + .for_user(user); + let links = repo + .upstream_oauth_link() + // Hardcoded limit of 100 links. We do not expect reasonably more links + // See also https://github.com/element-hq/matrix-authentication-service/pull/3245#discussion_r1776850096 + .list(filter, Pagination::first(100)) + .await?; + repo.cancel().await?; + + Ok(links + .edges + .into_iter() + .map(UpstreamOAuth2Link::new) + .collect()) + } } impl UpstreamOAuth2Link { diff --git a/crates/handlers/src/graphql/mutations/mod.rs b/crates/handlers/src/graphql/mutations/mod.rs index dbc56a518..7c8b797bd 100644 --- a/crates/handlers/src/graphql/mutations/mod.rs +++ b/crates/handlers/src/graphql/mutations/mod.rs @@ -8,6 +8,7 @@ mod browser_session; mod compat_session; mod matrix; mod oauth2_session; +mod upstream_oauth; mod user; mod user_email; @@ -22,6 +23,7 @@ pub struct Mutation( compat_session::CompatSessionMutations, browser_session::BrowserSessionMutations, matrix::MatrixMutations, + upstream_oauth::UpstreamOauthMutations, ); impl Mutation { diff --git a/crates/handlers/src/graphql/mutations/upstream_oauth.rs b/crates/handlers/src/graphql/mutations/upstream_oauth.rs new file mode 100644 index 000000000..a41530aae --- /dev/null +++ b/crates/handlers/src/graphql/mutations/upstream_oauth.rs @@ -0,0 +1,151 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context as _; +use async_graphql::{Context, Description, Enum, InputObject, Object, ID}; +use mas_storage::{user::UserRepository, RepositoryAccess}; + +use crate::graphql::{ + model::{NodeType, UpstreamOAuth2Link, UpstreamOAuth2Provider, User}, + state::ContextExt, +}; + +#[derive(Default)] +pub struct UpstreamOauthMutations { + _private: (), +} + +/// The input for the `removeEmail` mutation +#[derive(InputObject)] +struct RemoveUpstreamLinkInput { + /// The ID of the upstream link to remove + upstream_link_id: ID, +} + +/// The status of the `removeEmail` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum RemoveUpstreamLinkStatus { + /// The upstream link was removed + Removed, + + /// The upstream link was not found + NotFound, +} + +/// The payload of the `removeEmail` mutation +#[derive(Description)] +enum RemoveUpstreamLinkPayload { + Removed(mas_data_model::UpstreamOAuthLink), + NotFound, +} + +#[Object(use_type_description)] +impl RemoveUpstreamLinkPayload { + /// Status of the operation + async fn status(&self) -> RemoveUpstreamLinkStatus { + match self { + RemoveUpstreamLinkPayload::Removed(_) => RemoveUpstreamLinkStatus::Removed, + RemoveUpstreamLinkPayload::NotFound => RemoveUpstreamLinkStatus::NotFound, + } + } + + /// The upstream link that was removed + async fn upstream_link(&self) -> Option { + match self { + RemoveUpstreamLinkPayload::Removed(link) => Some(UpstreamOAuth2Link::new(link.clone())), + RemoveUpstreamLinkPayload::NotFound => None, + } + } + + /// The provider to which the upstream link belonged + async fn provider( + &self, + ctx: &Context<'_>, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let provider_id = match self { + RemoveUpstreamLinkPayload::Removed(link) => link.provider_id, + RemoveUpstreamLinkPayload::NotFound => return Ok(None), + }; + + let mut repo = state.repository().await?; + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .context("Upstream OAuth 2.0 provider not found")?; + + Ok(Some(UpstreamOAuth2Provider::new(provider))) + } + + /// The user to whom the upstream link belonged + async fn user(&self, ctx: &Context<'_>) -> Result, async_graphql::Error> { + let state = ctx.state(); + let mut repo = state.repository().await?; + + let user_id = match self { + RemoveUpstreamLinkPayload::Removed(link) => link.user_id, + RemoveUpstreamLinkPayload::NotFound => return Ok(None), + }; + + match user_id { + None => Ok(None), + Some(user_id) => { + let user = repo + .user() + .lookup(user_id) + .await? + .context("User not found")?; + + Ok(Some(User(user))) + } + } + } +} + +#[Object] +impl UpstreamOauthMutations { + /// Remove an upstream linked account + async fn remove_upstream_link( + &self, + ctx: &Context<'_>, + input: RemoveUpstreamLinkInput, + ) -> Result { + let state = ctx.state(); + let upstream_link_id = + NodeType::UpstreamOAuth2Link.extract_ulid(&input.upstream_link_id)?; + let requester = ctx.requester(); + + let mut repo = state.repository().await?; + + let upstream_link = repo.upstream_oauth_link().lookup(upstream_link_id).await?; + let Some(upstream_link) = upstream_link else { + return Ok(RemoveUpstreamLinkPayload::NotFound); + }; + + if !requester.is_owner_or_admin(&upstream_link) { + return Ok(RemoveUpstreamLinkPayload::NotFound); + } + + // Allow non-admins to remove their email address if the site config allows it + if !requester.is_admin() && !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let upstream_link = repo + .upstream_oauth_link() + .lookup(upstream_link.id) + .await? + .context("Failed to load user")?; + + repo.upstream_oauth_link() + .remove(upstream_link.clone()) + .await?; + + repo.save().await?; + + Ok(RemoveUpstreamLinkPayload::Removed(upstream_link)) + } +} diff --git a/crates/storage-pg/.sqlx/query-5edffe16eb0e038083d423bcd753300d2ba034f0e350ad57ab9aeeddefd88701.json b/crates/storage-pg/.sqlx/query-5edffe16eb0e038083d423bcd753300d2ba034f0e350ad57ab9aeeddefd88701.json new file mode 100644 index 000000000..9cd299b13 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-5edffe16eb0e038083d423bcd753300d2ba034f0e350ad57ab9aeeddefd88701.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE upstream_oauth_authorization_sessions SET upstream_oauth_link_id = NULL\n WHERE upstream_oauth_link_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5edffe16eb0e038083d423bcd753300d2ba034f0e350ad57ab9aeeddefd88701" +} diff --git a/crates/storage-pg/.sqlx/query-cc60ad934d347fb4546205d1fe07e9d2f127cb15b1bb650d1ea3805a4c55b196.json b/crates/storage-pg/.sqlx/query-cc60ad934d347fb4546205d1fe07e9d2f127cb15b1bb650d1ea3805a4c55b196.json new file mode 100644 index 000000000..00e04bffa --- /dev/null +++ b/crates/storage-pg/.sqlx/query-cc60ad934d347fb4546205d1fe07e9d2f127cb15b1bb650d1ea3805a4c55b196.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM upstream_oauth_links\n WHERE upstream_oauth_link_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cc60ad934d347fb4546205d1fe07e9d2f127cb15b1bb650d1ea3805a4c55b196" +} diff --git a/crates/storage-pg/src/upstream_oauth2/link.rs b/crates/storage-pg/src/upstream_oauth2/link.rs index 02ae4ca62..09b59cabf 100644 --- a/crates/storage-pg/src/upstream_oauth2/link.rs +++ b/crates/storage-pg/src/upstream_oauth2/link.rs @@ -14,7 +14,7 @@ use mas_storage::{ use rand::RngCore; use sea_query::{enum_def, Expr, PostgresQueryBuilder, Query}; use sea_query_binder::SqlxBinder; -use sqlx::PgConnection; +use sqlx::{Connection, PgConnection}; use ulid::Ulid; use uuid::Uuid; @@ -355,4 +355,50 @@ impl<'c> UpstreamOAuthLinkRepository for PgUpstreamOAuthLinkRepository<'c> { .try_into() .map_err(DatabaseError::to_invalid_operation) } + + #[tracing::instrument( + name = "db.upstream_oauth_link.remove", + skip_all, + fields( + db.query.text, + upstream_oauth_link.id, + upstream_oauth_link.provider_id, + %upstream_oauth_link.subject, + ), + err, + )] + async fn remove(&mut self, upstream_oauth_link: UpstreamOAuthLink) -> Result<(), Self::Error> { + let mut tx = self.conn.begin().await?; + + // Unset the authorization sessions first, as they have a foreign key + // constraint on the links. + sqlx::query!( + r#" + UPDATE upstream_oauth_authorization_sessions SET upstream_oauth_link_id = NULL + WHERE upstream_oauth_link_id = $1 + "#, + Uuid::from(upstream_oauth_link.id), + ) + .traced() + .execute(&mut *tx) + .await?; + + // Then delete the link itself + let res = sqlx::query!( + r#" + DELETE FROM upstream_oauth_links + WHERE upstream_oauth_link_id = $1 + "#, + Uuid::from(upstream_oauth_link.id), + ) + .traced() + .execute(&mut *tx) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + tx.commit().await?; + + Ok(()) + } } diff --git a/crates/storage/src/upstream_oauth2/link.rs b/crates/storage/src/upstream_oauth2/link.rs index 3088fbab0..37056bcb0 100644 --- a/crates/storage/src/upstream_oauth2/link.rs +++ b/crates/storage/src/upstream_oauth2/link.rs @@ -184,6 +184,17 @@ pub trait UpstreamOAuthLinkRepository: Send + Sync { /// /// Returns [`Self::Error`] if the underlying repository fails async fn count(&mut self, filter: UpstreamOAuthLinkFilter<'_>) -> Result; + + /// Delete a [`UpstreamOAuthLink`] + /// + /// # Parameters + /// + /// * `upstream_oauth_link`: The [`UpstreamOAuthLink`] to delete + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn remove(&mut self, upstream_oauth_link: UpstreamOAuthLink) -> Result<(), Self::Error>; } repository_impl!(UpstreamOAuthLinkRepository: @@ -216,4 +227,6 @@ repository_impl!(UpstreamOAuthLinkRepository: ) -> Result, Self::Error>; async fn count(&mut self, filter: UpstreamOAuthLinkFilter<'_>) -> Result; + + async fn remove(&mut self, upstream_oauth_link: UpstreamOAuthLink) -> Result<(), Self::Error>; ); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 15ee71401..c5d592faf 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -35,6 +35,10 @@ "title": "Edit profile", "username_label": "Username" }, + "linked_upstreams": { + "description": "Sign in with another account provider that supports Single Sign On (SSO).", + "heading": "Linked accounts" + }, "password": { "change": "Change password", "change_disabled": "Password changes are disabled by the administrator.", @@ -91,6 +95,9 @@ "active_now": "Active now", "inactive_90_days": "Inactive for 90+ days" }, + "link_upstream_button": { + "text": "Link to {{provider}}" + }, "nav": { "devices": "Devices", "settings": "Settings" @@ -232,6 +239,10 @@ "title": "Cannot find session: {{deviceId}}" } }, + "unlink_upstream_button": { + "confirmation_modal_title": "Unlink your {{provider}} account?", + "text": "Unlink {{provider}}" + }, "unverified_email_alert": { "button": "Review and verify", "text:one": "You have {{count}} unverified email address.", @@ -289,4 +300,4 @@ "view_profile": "See your profile info and contact details" } } -} +} \ No newline at end of file diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 3fd167f10..de04bcd9a 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -818,6 +818,12 @@ type Mutation { Set the display name of a user """ setDisplayName(input: SetDisplayNameInput!): SetDisplayNamePayload! + """ + Remove an upstream linked account + """ + removeUpstreamLink( + input: RemoveUpstreamLinkInput! + ): RemoveUpstreamLinkPayload! } """ @@ -1161,6 +1167,52 @@ enum RemoveEmailStatus { NOT_FOUND } +""" +The input for the `removeEmail` mutation +""" +input RemoveUpstreamLinkInput { + """ + The ID of the upstream link to remove + """ + upstreamLinkId: ID! +} + +""" +The payload of the `removeEmail` mutation +""" +type RemoveUpstreamLinkPayload { + """ + Status of the operation + """ + status: RemoveUpstreamLinkStatus! + """ + The upstream link that was removed + """ + upstreamLink: UpstreamOAuth2Link + """ + The provider to which the upstream link belonged + """ + provider: UpstreamOAuth2Provider + """ + The user to whom the upstream link belonged + """ + user: User +} + +""" +The status of the `removeEmail` mutation +""" +enum RemoveUpstreamLinkStatus { + """ + The upstream link was removed + """ + REMOVED + """ + The upstream link was not found + """ + NOT_FOUND +} + """ The input for the `sendVerificationEmail` mutation """ @@ -1594,6 +1646,29 @@ type UpstreamOAuth2Provider implements Node & CreationEvent { Client ID used for this provider. """ clientId: String! + """ + The human-readable name of the provider. + """ + humanName: String + """ + The brand name of the provider. + + Values supported by the default template are: + + - `apple` + - `google` + - `facebook` + - `github` + - `gitlab` + - `twitter` + + Note that this is a free-form field and can be any string value. + """ + brandName: String + """ + UpstreamOAuth2Links associated with this provider for the current user. + """ + upstreamOauth2LinksForUser: [UpstreamOAuth2Link!]! } type UpstreamOAuth2ProviderConnection { diff --git a/frontend/src/components/UpstreamProvider/LinkUpstreamProvider.tsx b/frontend/src/components/UpstreamProvider/LinkUpstreamProvider.tsx new file mode 100644 index 000000000..8823156e4 --- /dev/null +++ b/frontend/src/components/UpstreamProvider/LinkUpstreamProvider.tsx @@ -0,0 +1,93 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import IconUserAdd from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; +import { Button } from "@vector-im/compound-web"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import GoogleLogo from "./logos/google"; +import GithubLogo from "./logos/github"; +import GitlabLogo from "./logos/gitlab"; +import TwitterLogo from "./logos/twitter"; +import FacebookLogo from "./logos/facebook"; +import AppleLogo from "./logos/apple"; + +import { FragmentType, graphql, useFragment } from "../../gql"; +import LoadingSpinner from "../LoadingSpinner"; + +const FRAGMENT = graphql(/* GraphQL */ ` + fragment LinkUpstreamProvider_provider on UpstreamOAuth2Provider { + id + humanName + brandName + } +`); + +const LinkUpstreamProvider: React.FC<{ + upstreamProvider: FragmentType; + disabled?: boolean; +}> = ({ upstreamProvider, disabled }) => { + const data = useFragment(FRAGMENT, upstreamProvider); + const [inProgress, setInProgress] = useState(false); + const { t } = useTranslation(); + + const onConfirm = async ( + e: React.MouseEvent, + ): Promise => { + e.preventDefault(); + + setInProgress(true); + const upstreamURL = `/upstream/authorize/${data.id.replace("upstream_oauth2_provider:", "")}`; + window.location.replace(upstreamURL); + setInProgress(false); + }; + + // Pick the right svg from the brand name + // + // Supported upstream providers: + // - Google + // - GitHub + // - GitLab + // - Twitter + // - Facebook + // - Apple + const logo = (function (brandName?: string | null) { + if (!brandName) { + return null; + } + if (brandName.toLowerCase() === "google") { + return GoogleLogo; + } else if (brandName.toLowerCase() === "github") { + return GithubLogo; + } else if (brandName.toLowerCase() === "gitlab") { + return GitlabLogo; + } else if (brandName.toLowerCase() === "twitter") { + return TwitterLogo; + } else if (brandName.toLowerCase() === "facebook") { + return FacebookLogo; + } else if (brandName.toLowerCase() === "apple") { + return AppleLogo; + } + })(data.brandName); + + return ( + <> + + + ); +}; + +export default LinkUpstreamProvider; diff --git a/frontend/src/components/UpstreamProvider/UnlinkUpstreamProvider.tsx b/frontend/src/components/UpstreamProvider/UnlinkUpstreamProvider.tsx new file mode 100644 index 000000000..52ae919fa --- /dev/null +++ b/frontend/src/components/UpstreamProvider/UnlinkUpstreamProvider.tsx @@ -0,0 +1,110 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out"; +import { Button } from "@vector-im/compound-web"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation } from "urql"; + +import { FragmentType, graphql, useFragment } from "../../gql"; +import * as Dialog from "../Dialog"; +import LoadingSpinner from "../LoadingSpinner/LoadingSpinner"; + +const FRAGMENT = graphql(/* GraphQL */ ` + fragment UnlinkUpstreamProvider_provider on UpstreamOAuth2Provider { + id + createdAt + humanName + upstreamOauth2LinksForUser { + id + provider { + id + } + } + } +`); + +export const MUTATION = graphql(/* GraphQL */ ` + mutation RemoveUpstreamLink($id: ID!) { + removeUpstreamLink(input: { upstreamLinkId: $id }) { + status + } + } +`); + +const UnlinkUpstreamButton: React.FC< + React.PropsWithChildren<{ + upstreamProvider: FragmentType; + onUnlinked?: () => void; + }> +> = ({ children, upstreamProvider, onUnlinked }) => { + const [inProgress, setInProgress] = useState(false); + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + const data = useFragment(FRAGMENT, upstreamProvider); + const [, removeUpstreamLink] = useMutation(MUTATION); + + const onConfirm = async ( + e: React.MouseEvent, + ): Promise => { + e.preventDefault(); + + setInProgress(true); + if (!data.upstreamOauth2LinksForUser) { + return; + } + + // We assume only one exists but since its an array we remove all + for (const link of data.upstreamOauth2LinksForUser) { + // FIXME: We should handle errors here + await removeUpstreamLink({ id: link.id }); + } + onUnlinked && onUnlinked(); + setInProgress(false); + }; + + return ( + + {t("frontend.unlink_upstream_button.text", { + provider: data.humanName, + })} + + } + > + + {t("frontend.unlink_upstream_button.confirmation_modal_title", { + provider: data.humanName, + })} + + + {children && {children}} + + + + + + + + ); +}; + +export default UnlinkUpstreamButton; diff --git a/frontend/src/components/UpstreamProvider/logos/apple.tsx b/frontend/src/components/UpstreamProvider/logos/apple.tsx new file mode 100644 index 000000000..0143280cb --- /dev/null +++ b/frontend/src/components/UpstreamProvider/logos/apple.tsx @@ -0,0 +1,18 @@ +export default function AppleLogo() { + return ( + + + + ); +} diff --git a/frontend/src/components/UpstreamProvider/logos/facebook.tsx b/frontend/src/components/UpstreamProvider/logos/facebook.tsx new file mode 100644 index 000000000..24b201ac9 --- /dev/null +++ b/frontend/src/components/UpstreamProvider/logos/facebook.tsx @@ -0,0 +1,24 @@ +export default function FacebookLogo() { + return ( + + + + + ); +} diff --git a/frontend/src/components/UpstreamProvider/logos/github.tsx b/frontend/src/components/UpstreamProvider/logos/github.tsx new file mode 100644 index 000000000..af331eb37 --- /dev/null +++ b/frontend/src/components/UpstreamProvider/logos/github.tsx @@ -0,0 +1,16 @@ +export default function GithubLogo() { + return ( + + + + ); +} diff --git a/frontend/src/components/UpstreamProvider/logos/gitlab.tsx b/frontend/src/components/UpstreamProvider/logos/gitlab.tsx new file mode 100644 index 000000000..3e19932d7 --- /dev/null +++ b/frontend/src/components/UpstreamProvider/logos/gitlab.tsx @@ -0,0 +1,40 @@ +export default function GitlabLogo() { + return ( + + + + + + + + + + ); +} diff --git a/frontend/src/components/UpstreamProvider/logos/google.tsx b/frontend/src/components/UpstreamProvider/logos/google.tsx new file mode 100644 index 000000000..5e7e9436f --- /dev/null +++ b/frontend/src/components/UpstreamProvider/logos/google.tsx @@ -0,0 +1,28 @@ +export default function GoogleLogo() { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/UpstreamProvider/logos/twitter.tsx b/frontend/src/components/UpstreamProvider/logos/twitter.tsx new file mode 100644 index 000000000..89f060085 --- /dev/null +++ b/frontend/src/components/UpstreamProvider/logos/twitter.tsx @@ -0,0 +1,16 @@ +export default function TwitterLogo() { + return ( + + + + ); +} diff --git a/frontend/src/components/UserProfile/UpstreamProviderList.tsx b/frontend/src/components/UserProfile/UpstreamProviderList.tsx new file mode 100644 index 000000000..4e5bcecaf --- /dev/null +++ b/frontend/src/components/UserProfile/UpstreamProviderList.tsx @@ -0,0 +1,127 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { H5 } from "@vector-im/compound-web"; +import { useCallback, useTransition } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "urql"; + +import { graphql } from "../../gql"; +import { Pagination, usePages, usePagination } from "../../pagination"; +import PaginationControls from "../PaginationControls"; +import LinkUpstreamProvider from "../UpstreamProvider/LinkUpstreamProvider"; +import UnlinkUpstreamProvider from "../UpstreamProvider/UnlinkUpstreamProvider"; + +const QUERY = graphql(/* GraphQL */ ` + query UpstreamProviderListQuery( + $first: Int + $after: String + $last: Int + $before: String + ) { + upstreamOauth2Providers( + first: $first + after: $after + last: $last + before: $before + ) { + edges { + cursor + node { + id + upstreamOauth2LinksForUser { + id + provider { + id + } + } + ...LinkUpstreamProvider_provider + ...UnlinkUpstreamProvider_provider + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`); + +const UpstreamProviderList: React.FC<{}> = () => { + const [pending, startTransition] = useTransition(); + const { t } = useTranslation(); + + const [pagination, setPagination] = usePagination(); + const [result, update] = useQuery({ + query: QUERY, + variables: { ...pagination }, + }); + if (result.error) throw result.error; + const links = result.data?.upstreamOauth2Providers; + if (!links) throw new Error(); // Suspense mode is enabled + + const [prevPage, nextPage] = usePages(pagination, links.pageInfo); + const paginate = (pagination: Pagination): void => { + startTransition(() => { + setPagination(pagination); + }); + }; + + const onUnlinked = useCallback(() => { + update({ requestPolicy: "network-only" }); + }, [update]); + + const linkedUpstreams = links.edges.filter((edge) => + edge.node.upstreamOauth2LinksForUser.some( + (link) => link.provider.id === edge.node.id, + ), + ); + + if (links.totalCount === 0) { + return <>; + } + + return ( + <> + {linkedUpstreams.length > 0 && ( + <> + {linkedUpstreams.map((edge) => ( + + ))} + + )} + {links.edges + .filter( + (edge) => + !edge.node.upstreamOauth2LinksForUser.some( + (link) => link.provider.id === edge.node.id, + ), + ) + .map((edge) => ( + + ))} + + paginate(prevPage) : null} + onNext={nextPage ? (): void => paginate(nextPage) : null} + disabled={pending} + /> + + ); +}; + +export default UpstreamProviderList; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 1782c82af..09340611a 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -29,6 +29,9 @@ const documents = { "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc, "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc, "\n fragment UnverifiedEmailAlert_user on User {\n id\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n": types.UnverifiedEmailAlert_UserFragmentDoc, + "\n fragment LinkUpstreamProvider_provider on UpstreamOAuth2Provider {\n id\n humanName\n brandName\n }\n": types.LinkUpstreamProvider_ProviderFragmentDoc, + "\n fragment UnlinkUpstreamProvider_provider on UpstreamOAuth2Provider {\n id\n createdAt\n humanName\n upstreamOauth2LinksForUser {\n id\n provider {\n id\n }\n }\n }\n": types.UnlinkUpstreamProvider_ProviderFragmentDoc, + "\n mutation RemoveUpstreamLink($id: ID!) {\n removeUpstreamLink(input: { upstreamLinkId: $id }) {\n status\n }\n }\n": types.RemoveUpstreamLinkDocument, "\n fragment UserEmail_email on UserEmail {\n id\n email\n confirmedAt\n }\n": types.UserEmail_EmailFragmentDoc, "\n fragment UserEmail_siteConfig on SiteConfig {\n id\n emailChangeAllowed\n }\n": types.UserEmail_SiteConfigFragmentDoc, "\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument, @@ -37,6 +40,7 @@ const documents = { "\n fragment UserGreeting_siteConfig on SiteConfig {\n id\n displayNameChangeAllowed\n }\n": types.UserGreeting_SiteConfigFragmentDoc, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n user {\n id\n matrix {\n displayName\n }\n }\n }\n }\n": types.SetDisplayNameDocument, "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.AddEmailDocument, + "\n query UpstreamProviderListQuery(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n upstreamOauth2Providers(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n upstreamOauth2LinksForUser {\n id\n provider {\n id\n }\n }\n ...LinkUpstreamProvider_provider\n ...UnlinkUpstreamProvider_provider\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.UpstreamProviderListQueryDocument, "\n query UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.UserEmailListQueryDocument, "\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n": types.UserEmailList_UserFragmentDoc, "\n fragment UserEmailList_siteConfig on SiteConfig {\n id\n ...UserEmail_siteConfig\n }\n": types.UserEmailList_SiteConfigFragmentDoc, @@ -135,6 +139,18 @@ export function graphql(source: "\n fragment OAuth2Session_detail on Oauth2Sess * 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 UnverifiedEmailAlert_user on User {\n id\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment UnverifiedEmailAlert_user on User {\n id\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n"]; +/** + * 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 LinkUpstreamProvider_provider on UpstreamOAuth2Provider {\n id\n humanName\n brandName\n }\n"): (typeof documents)["\n fragment LinkUpstreamProvider_provider on UpstreamOAuth2Provider {\n id\n humanName\n brandName\n }\n"]; +/** + * 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 UnlinkUpstreamProvider_provider on UpstreamOAuth2Provider {\n id\n createdAt\n humanName\n upstreamOauth2LinksForUser {\n id\n provider {\n id\n }\n }\n }\n"): (typeof documents)["\n fragment UnlinkUpstreamProvider_provider on UpstreamOAuth2Provider {\n id\n createdAt\n humanName\n upstreamOauth2LinksForUser {\n id\n provider {\n id\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation RemoveUpstreamLink($id: ID!) {\n removeUpstreamLink(input: { upstreamLinkId: $id }) {\n status\n }\n }\n"): (typeof documents)["\n mutation RemoveUpstreamLink($id: ID!) {\n removeUpstreamLink(input: { upstreamLinkId: $id }) {\n status\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -167,6 +183,10 @@ export function graphql(source: "\n mutation SetDisplayName($userId: ID!, $disp * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): (typeof documents)["\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"]; +/** + * 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 UpstreamProviderListQuery(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n upstreamOauth2Providers(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n upstreamOauth2LinksForUser {\n id\n provider {\n id\n }\n }\n ...LinkUpstreamProvider_provider\n ...UnlinkUpstreamProvider_provider\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query UpstreamProviderListQuery(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n upstreamOauth2Providers(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n upstreamOauth2LinksForUser {\n id\n provider {\n id\n }\n }\n ...LinkUpstreamProvider_provider\n ...UnlinkUpstreamProvider_provider\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"]; /** * 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 a7656d5d2..4eac77f21 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -492,6 +492,8 @@ export type Mutation = { lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; + /** Remove an upstream linked account */ + removeUpstreamLink: RemoveUpstreamLinkPayload; /** Send a verification code for an email address */ sendVerificationEmail: SendVerificationEmailPayload; /** @@ -575,6 +577,12 @@ export type MutationRemoveEmailArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationRemoveUpstreamLinkArgs = { + input: RemoveUpstreamLinkInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationSendVerificationEmailArgs = { input: SendVerificationEmailInput; @@ -886,6 +894,32 @@ export type RemoveEmailStatus = /** The email address was removed */ | 'REMOVED'; +/** The input for the `removeEmail` mutation */ +export type RemoveUpstreamLinkInput = { + /** The ID of the upstream link to remove */ + upstreamLinkId: Scalars['ID']['input']; +}; + +/** The payload of the `removeEmail` mutation */ +export type RemoveUpstreamLinkPayload = { + __typename?: 'RemoveUpstreamLinkPayload'; + /** The provider to which the upstream link belonged */ + provider?: Maybe; + /** Status of the operation */ + status: RemoveUpstreamLinkStatus; + /** The upstream link that was removed */ + upstreamLink?: Maybe; + /** The user to whom the upstream link belonged */ + user?: Maybe; +}; + +/** The status of the `removeEmail` mutation */ +export type RemoveUpstreamLinkStatus = + /** The upstream link was not found */ + | 'NOT_FOUND' + /** The upstream link was removed */ + | 'REMOVED'; + /** The input for the `sendVerificationEmail` mutation */ export type SendVerificationEmailInput = { /** The ID of the email address to verify */ @@ -1148,14 +1182,33 @@ export type UpstreamOAuth2LinkEdge = { export type UpstreamOAuth2Provider = CreationEvent & Node & { __typename?: 'UpstreamOAuth2Provider'; + /** + * The brand name of the provider. + * + * Values supported by the default template are: + * + * - `apple` + * - `google` + * - `facebook` + * - `github` + * - `gitlab` + * - `twitter` + * + * Note that this is a free-form field and can be any string value. + */ + brandName?: Maybe; /** Client ID used for this provider. */ clientId: Scalars['String']['output']; /** When the object was created. */ createdAt: Scalars['DateTime']['output']; + /** The human-readable name of the provider. */ + humanName?: Maybe; /** ID of the object. */ id: Scalars['ID']['output']; /** OpenID Connect issuer URL. */ issuer: Scalars['String']['output']; + /** UpstreamOAuth2Links associated with this provider for the current user. */ + upstreamOauth2LinksForUser: Array; }; export type UpstreamOAuth2ProviderConnection = { @@ -1473,6 +1526,17 @@ export type OAuth2Session_DetailFragment = { __typename?: 'Oauth2Session', id: s export type UnverifiedEmailAlert_UserFragment = { __typename?: 'User', id: string, unverifiedEmails: { __typename?: 'UserEmailConnection', totalCount: number } } & { ' $fragmentName'?: 'UnverifiedEmailAlert_UserFragment' }; +export type LinkUpstreamProvider_ProviderFragment = { __typename?: 'UpstreamOAuth2Provider', id: string, humanName?: string | null, brandName?: string | null } & { ' $fragmentName'?: 'LinkUpstreamProvider_ProviderFragment' }; + +export type UnlinkUpstreamProvider_ProviderFragment = { __typename?: 'UpstreamOAuth2Provider', id: string, createdAt: string, humanName?: string | null, upstreamOauth2LinksForUser: Array<{ __typename?: 'UpstreamOAuth2Link', id: string, provider: { __typename?: 'UpstreamOAuth2Provider', id: string } }> } & { ' $fragmentName'?: 'UnlinkUpstreamProvider_ProviderFragment' }; + +export type RemoveUpstreamLinkMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type RemoveUpstreamLinkMutation = { __typename?: 'Mutation', removeUpstreamLink: { __typename?: 'RemoveUpstreamLinkPayload', status: RemoveUpstreamLinkStatus } }; + export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string, confirmedAt?: string | null } & { ' $fragmentName'?: 'UserEmail_EmailFragment' }; export type UserEmail_SiteConfigFragment = { __typename?: 'SiteConfig', id: string, emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmail_SiteConfigFragment' }; @@ -1514,6 +1578,19 @@ export type AddEmailMutation = { __typename?: 'Mutation', addEmail: { __typename & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } ) | null } }; +export type UpstreamProviderListQueryQueryVariables = Exact<{ + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; +}>; + + +export type UpstreamProviderListQueryQuery = { __typename?: 'Query', upstreamOauth2Providers: { __typename?: 'UpstreamOAuth2ProviderConnection', totalCount: number, edges: Array<{ __typename?: 'UpstreamOAuth2ProviderEdge', cursor: string, node: ( + { __typename?: 'UpstreamOAuth2Provider', id: string, upstreamOauth2LinksForUser: Array<{ __typename?: 'UpstreamOAuth2Link', id: string, provider: { __typename?: 'UpstreamOAuth2Provider', id: string } }> } + & { ' $fragmentRefs'?: { 'LinkUpstreamProvider_ProviderFragment': LinkUpstreamProvider_ProviderFragment;'UnlinkUpstreamProvider_ProviderFragment': UnlinkUpstreamProvider_ProviderFragment } } + ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; + export type UserEmailListQueryQueryVariables = Exact<{ userId: Scalars['ID']['input']; first?: InputMaybe; @@ -1724,6 +1801,8 @@ export const BrowserSession_DetailFragmentDoc = {"kind":"Document","definitions" export const CompatSession_DetailFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CompatSession_detail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CompatSession"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"deviceId"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveIp"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveAt"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"os"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}},{"kind":"Field","name":{"kind":"Name","value":"ssoLogin"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"redirectUri"}}]}}]}}]} as unknown as DocumentNode; export const OAuth2Session_DetailFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OAuth2Session_detail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Oauth2Session"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveIp"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveAt"}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"clientName"}},{"kind":"Field","name":{"kind":"Name","value":"clientUri"}},{"kind":"Field","name":{"kind":"Name","value":"logoUri"}}]}}]}}]} as unknown as DocumentNode; export const UnverifiedEmailAlert_UserFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UnverifiedEmailAlert_user"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","alias":{"kind":"Name","value":"unverifiedEmails"},"name":{"kind":"Name","value":"emails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"0"}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"EnumValue","value":"PENDING"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode; +export const LinkUpstreamProvider_ProviderFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LinkUpstreamProvider_provider"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UpstreamOAuth2Provider"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"humanName"}},{"kind":"Field","name":{"kind":"Name","value":"brandName"}}]}}]} as unknown as DocumentNode; +export const UnlinkUpstreamProvider_ProviderFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UnlinkUpstreamProvider_provider"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UpstreamOAuth2Provider"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"humanName"}},{"kind":"Field","name":{"kind":"Name","value":"upstreamOauth2LinksForUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const UserEmail_EmailFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_email"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"confirmedAt"}}]}}]} as unknown as DocumentNode; export const UserGreeting_UserFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserGreeting_user"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"matrix"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"mxid"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]} as unknown as DocumentNode; export const UserGreeting_SiteConfigFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserGreeting_siteConfig"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SiteConfig"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayNameChangeAllowed"}}]}}]} as unknown as DocumentNode; @@ -1736,10 +1815,12 @@ export const EndBrowserSessionDocument = {"kind":"Document","definitions":[{"kin export const EndCompatSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndCompatSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCompatSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"compatSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"compatSession"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const FooterQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FooterQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"siteConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"Footer_siteConfig"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Footer_siteConfig"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SiteConfig"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imprint"}},{"kind":"Field","name":{"kind":"Name","value":"tosUri"}},{"kind":"Field","name":{"kind":"Name","value":"policyUri"}}]}}]} as unknown as DocumentNode; export const EndOAuth2SessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndOAuth2Session"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endOauth2Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"oauth2SessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"oauth2Session"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"OAuth2Session_session"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OAuth2Session_session"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Oauth2Session"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveIp"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveAt"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"os"}},{"kind":"Field","name":{"kind":"Name","value":"deviceType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"clientName"}},{"kind":"Field","name":{"kind":"Name","value":"applicationType"}},{"kind":"Field","name":{"kind":"Name","value":"logoUri"}}]}}]}}]} as unknown as DocumentNode; +export const RemoveUpstreamLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveUpstreamLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeUpstreamLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"upstreamLinkId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; export const RemoveEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userEmailId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const SetPrimaryEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetPrimaryEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setPrimaryEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userEmailId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"primaryEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const SetDisplayNameDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetDisplayName"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"displayName"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setDisplayName"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"displayName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"displayName"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"matrix"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const AddEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"violations"}},{"kind":"Field","name":{"kind":"Name","value":"email"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserEmail_email"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_email"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"confirmedAt"}}]}}]} as unknown as DocumentNode; +export const UpstreamProviderListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UpstreamProviderListQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upstreamOauth2Providers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"upstreamOauth2LinksForUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LinkUpstreamProvider_provider"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UnlinkUpstreamProvider_provider"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LinkUpstreamProvider_provider"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UpstreamOAuth2Provider"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"humanName"}},{"kind":"Field","name":{"kind":"Name","value":"brandName"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UnlinkUpstreamProvider_provider"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UpstreamOAuth2Provider"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"humanName"}},{"kind":"Field","name":{"kind":"Name","value":"upstreamOauth2LinksForUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const UserEmailListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserEmailListQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserEmail_email"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_email"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"confirmedAt"}}]}}]} as unknown as DocumentNode; export const VerifyEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"code"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userEmailId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"code"},"value":{"kind":"Variable","name":{"kind":"Name","value":"code"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"primaryEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"email"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserEmail_email"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_email"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"confirmedAt"}}]}}]} as unknown as DocumentNode; export const ResendVerificationEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendVerificationEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendVerificationEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userEmailId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"primaryEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"email"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserEmail_email"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_email"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"confirmedAt"}}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index b716322b7..1142bf851 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -21,6 +21,7 @@ import * as Collapsible from "../components/Collapsible"; import LoadingSpinner from "../components/LoadingSpinner"; import UserEmail from "../components/UserEmail"; import AddEmailForm from "../components/UserProfile/AddEmailForm"; +import UpstreamProviderList from "../components/UserProfile/UpstreamProviderList"; import UserEmailList from "../components/UserProfile/UserEmailList"; import { QUERY } from "./_account.index"; @@ -81,6 +82,26 @@ function Index(): React.ReactElement { + + + + {t("frontend.account.linked_upstreams.heading")} + + + + + + {t("frontend.account.linked_upstreams.description")} + + } + > + + + + + + diff --git a/misc/update.sh b/misc/update.sh old mode 100644 new mode 100755