Skip to content

Commit

Permalink
Add mutation and UI to delete an upstream link
Browse files Browse the repository at this point in the history
Signed-off-by: MTRNord <[email protected]>
  • Loading branch information
MTRNord committed Sep 30, 2024
1 parent 9b7fe18 commit bc74e13
Show file tree
Hide file tree
Showing 13 changed files with 436 additions and 5 deletions.
2 changes: 2 additions & 0 deletions crates/handlers/src/graphql/mutations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod browser_session;
mod compat_session;
mod matrix;
mod oauth2_session;
mod upstream_oauth;
mod user;
mod user_email;

Expand All @@ -22,6 +23,7 @@ pub struct Mutation(
compat_session::CompatSessionMutations,
browser_session::BrowserSessionMutations,
matrix::MatrixMutations,
upstream_oauth::UpstreamOauthMutations,
);

impl Mutation {
Expand Down
152 changes: 152 additions & 0 deletions crates/handlers/src/graphql/mutations/upstream_oauth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// 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<UpstreamOAuth2Link> {
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<Option<UpstreamOAuth2Provider>, 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<Option<User>, 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 => return 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<RemoveUpstreamLinkPayload, async_graphql::Error> {
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))
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions crates/storage-pg/src/upstream_oauth2/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,4 +355,46 @@ 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> {
// 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 *self.conn)
.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 *self.conn)
.await?;

DatabaseError::ensure_affected_rows(&res, 1)?;

Ok(())
}
}
13 changes: 13 additions & 0 deletions crates/storage/src/upstream_oauth2/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize, Self::Error>;

/// 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:
Expand Down Expand Up @@ -216,4 +227,6 @@ repository_impl!(UpstreamOAuthLinkRepository:
) -> Result<Page<UpstreamOAuthLink>, Self::Error>;

async fn count(&mut self, filter: UpstreamOAuthLinkFilter<'_>) -> Result<usize, Self::Error>;

async fn remove(&mut self, upstream_oauth_link: UpstreamOAuthLink) -> Result<(), Self::Error>;
);
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,6 @@
"vite-plugin-graphql-codegen": "^3.3.8",
"vite-plugin-manifest-sri": "^0.2.0",
"vitest": "^2.0.5"
}
},
"packageManager": "[email protected]+sha512.d08425c8062f56d43bb8e84315864218af2492eb769e1f1ca40740f44e85bd148969382d651660363942e5909cb7ffcbef7ca0ae963ddc2c57a51243b4da8f56"
}
52 changes: 52 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}

"""
Expand Down Expand Up @@ -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
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign
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";
Expand All @@ -27,23 +28,42 @@ const FRAGMENT = graphql(/* GraphQL */ `
}
`);

export const MUTATION = graphql(/* GraphQL */ `
mutation RemoveUpstreamLink($id: ID!) {
removeUpstreamLink(input: { upstreamLinkId: $id }) {
status
}
}
`);

const UnlinkUpstreamButton: React.FC<
React.PropsWithChildren<{
upstreamProvider: FragmentType<typeof FRAGMENT>;
onUnlinked?: () => void;
}>
> = ({ children, upstreamProvider }) => {
> = ({ 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<HTMLButtonElement>,
): Promise<void> => {
e.preventDefault();

setInProgress(true);
// TODO: Unlink
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);
};

Expand Down
Loading

0 comments on commit bc74e13

Please sign in to comment.