You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-07-31 09:24:31 +03:00
Ability to remove emails
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3224,6 +3224,7 @@ dependencies = [
|
||||
"async-graphql",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"lettre",
|
||||
"mas-data-model",
|
||||
"mas-storage",
|
||||
"oauth2-types",
|
||||
|
@ -10,6 +10,7 @@ anyhow = "1.0.71"
|
||||
async-graphql = { version = "5.0.9", features = ["chrono", "url"] }
|
||||
async-trait = "0.1.68"
|
||||
chrono = "0.4.24"
|
||||
lettre = { version = "0.10.4", default-features = false }
|
||||
serde = { version = "1.0.163", features = ["derive"] }
|
||||
thiserror = "1.0.40"
|
||||
tokio = { version = "1.28.1", features = ["sync"] }
|
||||
|
@ -46,6 +46,8 @@ pub enum AddEmailStatus {
|
||||
Added,
|
||||
/// The email address already exists
|
||||
Exists,
|
||||
/// The email address is invalid
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// The payload of the `addEmail` mutation
|
||||
@ -53,6 +55,7 @@ pub enum AddEmailStatus {
|
||||
enum AddEmailPayload {
|
||||
Added(mas_data_model::UserEmail),
|
||||
Exists(mas_data_model::UserEmail),
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[Object(use_type_description)]
|
||||
@ -62,25 +65,28 @@ impl AddEmailPayload {
|
||||
match self {
|
||||
AddEmailPayload::Added(_) => AddEmailStatus::Added,
|
||||
AddEmailPayload::Exists(_) => AddEmailStatus::Exists,
|
||||
AddEmailPayload::Invalid => AddEmailStatus::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// The email address that was added
|
||||
async fn email(&self) -> UserEmail {
|
||||
async fn email(&self) -> Option<UserEmail> {
|
||||
match self {
|
||||
AddEmailPayload::Added(email) | AddEmailPayload::Exists(email) => {
|
||||
UserEmail(email.clone())
|
||||
Some(UserEmail(email.clone()))
|
||||
}
|
||||
AddEmailPayload::Invalid => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The user to whom the email address was added
|
||||
async fn user(&self, ctx: &Context<'_>) -> Result<User, async_graphql::Error> {
|
||||
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 {
|
||||
AddEmailPayload::Added(email) | AddEmailPayload::Exists(email) => email.user_id,
|
||||
AddEmailPayload::Invalid => return Ok(None),
|
||||
};
|
||||
|
||||
let user = repo
|
||||
@ -89,7 +95,7 @@ impl AddEmailPayload {
|
||||
.await?
|
||||
.context("User not found")?;
|
||||
|
||||
Ok(User(user))
|
||||
Ok(Some(User(user)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,6 +233,77 @@ impl VerifyEmailPayload {
|
||||
}
|
||||
}
|
||||
|
||||
/// The input for the `removeEmail` mutation
|
||||
#[derive(InputObject)]
|
||||
struct RemoveEmailInput {
|
||||
/// The ID of the email address to remove
|
||||
user_email_id: ID,
|
||||
}
|
||||
|
||||
/// The status of the `removeEmail` mutation
|
||||
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
|
||||
enum RemoveEmailStatus {
|
||||
/// The email address was removed
|
||||
Removed,
|
||||
|
||||
/// Can't remove the primary email address
|
||||
Primary,
|
||||
|
||||
/// The email address was not found
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// The payload of the `removeEmail` mutation
|
||||
#[derive(Description)]
|
||||
enum RemoveEmailPayload {
|
||||
Removed(mas_data_model::UserEmail),
|
||||
Primary(mas_data_model::UserEmail),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[Object(use_type_description)]
|
||||
impl RemoveEmailPayload {
|
||||
/// Status of the operation
|
||||
async fn status(&self) -> RemoveEmailStatus {
|
||||
match self {
|
||||
RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed,
|
||||
RemoveEmailPayload::Primary(_) => RemoveEmailStatus::Primary,
|
||||
RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// The email address that was removed
|
||||
async fn email(&self) -> Option<UserEmail> {
|
||||
match self {
|
||||
RemoveEmailPayload::Removed(email) | RemoveEmailPayload::Primary(email) => {
|
||||
Some(UserEmail(email.clone()))
|
||||
}
|
||||
RemoveEmailPayload::NotFound => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The user to whom the email address 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 {
|
||||
RemoveEmailPayload::Removed(email) | RemoveEmailPayload::Primary(email) => {
|
||||
email.user_id
|
||||
}
|
||||
RemoveEmailPayload::NotFound => return Ok(None),
|
||||
};
|
||||
|
||||
let user = repo
|
||||
.user()
|
||||
.lookup(user_id)
|
||||
.await?
|
||||
.context("User not found")?;
|
||||
|
||||
Ok(Some(User(user)))
|
||||
}
|
||||
}
|
||||
|
||||
#[Object]
|
||||
impl UserEmailMutations {
|
||||
/// Add an email address to the specified user
|
||||
@ -249,6 +326,12 @@ impl UserEmailMutations {
|
||||
|
||||
// XXX: this logic should be extracted somewhere else, since most of it is
|
||||
// duplicated in mas_handlers
|
||||
|
||||
// Validate the email address
|
||||
if input.email.parse::<lettre::Address>().is_err() {
|
||||
return Ok(AddEmailPayload::Invalid);
|
||||
}
|
||||
|
||||
// Find an existing email address
|
||||
let existing_user_email = repo.user_email().find(user, &input.email).await?;
|
||||
let (added, user_email) = if let Some(user_email) = existing_user_email {
|
||||
@ -388,4 +471,39 @@ impl UserEmailMutations {
|
||||
|
||||
Ok(VerifyEmailPayload::Verified(user_email))
|
||||
}
|
||||
|
||||
/// Remove an email address
|
||||
async fn remove_email(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
input: RemoveEmailInput,
|
||||
) -> Result<RemoveEmailPayload, async_graphql::Error> {
|
||||
let state = ctx.state();
|
||||
let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?;
|
||||
let requester = ctx.requester();
|
||||
|
||||
let user = requester.user().context("Unauthorized")?;
|
||||
|
||||
let mut repo = state.repository().await?;
|
||||
|
||||
let user_email = repo.user_email().lookup(user_email_id).await?;
|
||||
let Some(user_email) = user_email else {
|
||||
return Ok(RemoveEmailPayload::NotFound);
|
||||
};
|
||||
|
||||
if user_email.user_id != user.id {
|
||||
return Err(async_graphql::Error::new("Unauthorized"));
|
||||
}
|
||||
|
||||
if user.primary_user_email_id == Some(user_email.id) {
|
||||
// Prevent removing the primary email address
|
||||
return Ok(RemoveEmailPayload::Primary(user_email));
|
||||
}
|
||||
|
||||
repo.user_email().remove(user_email.clone()).await?;
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
Ok(RemoveEmailPayload::Removed(user_email))
|
||||
}
|
||||
}
|
||||
|
@ -23,11 +23,11 @@ type AddEmailPayload {
|
||||
"""
|
||||
The email address that was added
|
||||
"""
|
||||
email: UserEmail!
|
||||
email: UserEmail
|
||||
"""
|
||||
The user to whom the email address was added
|
||||
"""
|
||||
user: User!
|
||||
user: User
|
||||
}
|
||||
|
||||
"""
|
||||
@ -42,6 +42,10 @@ enum AddEmailStatus {
|
||||
The email address already exists
|
||||
"""
|
||||
EXISTS
|
||||
"""
|
||||
The email address is invalid
|
||||
"""
|
||||
INVALID
|
||||
}
|
||||
|
||||
type Anonymous implements Node {
|
||||
@ -237,6 +241,10 @@ type Mutation {
|
||||
Submit a verification code for an email address
|
||||
"""
|
||||
verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload!
|
||||
"""
|
||||
Remove an email address
|
||||
"""
|
||||
removeEmail(input: RemoveEmailInput!): RemoveEmailPayload!
|
||||
}
|
||||
|
||||
"""
|
||||
@ -421,6 +429,52 @@ type Query {
|
||||
viewerSession: ViewerSession!
|
||||
}
|
||||
|
||||
"""
|
||||
The input for the `removeEmail` mutation
|
||||
"""
|
||||
input RemoveEmailInput {
|
||||
"""
|
||||
The ID of the email address to remove
|
||||
"""
|
||||
userEmailId: ID!
|
||||
}
|
||||
|
||||
"""
|
||||
The payload of the `removeEmail` mutation
|
||||
"""
|
||||
type RemoveEmailPayload {
|
||||
"""
|
||||
Status of the operation
|
||||
"""
|
||||
status: RemoveEmailStatus!
|
||||
"""
|
||||
The email address that was removed
|
||||
"""
|
||||
email: UserEmail
|
||||
"""
|
||||
The user to whom the email address belonged
|
||||
"""
|
||||
user: User
|
||||
}
|
||||
|
||||
"""
|
||||
The status of the `removeEmail` mutation
|
||||
"""
|
||||
enum RemoveEmailStatus {
|
||||
"""
|
||||
The email address was removed
|
||||
"""
|
||||
REMOVED
|
||||
"""
|
||||
Can't remove the primary email address
|
||||
"""
|
||||
PRIMARY
|
||||
"""
|
||||
The email address was not found
|
||||
"""
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
"""
|
||||
The input for the `sendVerificationEmail` mutation
|
||||
"""
|
||||
|
@ -58,7 +58,12 @@ const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get("email") as string;
|
||||
startTransition(() => {
|
||||
addEmail({ userId, email }).then(() => {
|
||||
addEmail({ userId, email }).then((result) => {
|
||||
// Don't clear the form if the email was invalid
|
||||
if (result.data?.addEmail.status === "INVALID") {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// Paginate to the last page
|
||||
setCurrentPagination(LAST_PAGE);
|
||||
@ -74,22 +79,33 @@ const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const status = addEmailResult.data?.addEmail.status ?? null;
|
||||
const emailAdded = status === "ADDED";
|
||||
const emailExists = status === "EXISTS";
|
||||
const emailInvalid = status === "INVALID";
|
||||
|
||||
return (
|
||||
<>
|
||||
{addEmailResult.data?.addEmail.status === "ADDED" && (
|
||||
<>
|
||||
{emailAdded && (
|
||||
<div className="pt-4">
|
||||
<Typography variant="subtitle">Email added!</Typography>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{addEmailResult.data?.addEmail.status === "EXISTS" && (
|
||||
<>
|
||||
|
||||
{emailExists && (
|
||||
<div className="pt-4">
|
||||
<Typography variant="subtitle">Email already exists!</Typography>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{emailInvalid && (
|
||||
<div className="pt-4 text-alert">
|
||||
<Typography variant="subtitle" bold>
|
||||
Invalid email address
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="flex" onSubmit={handleSubmit} ref={formRef}>
|
||||
<Input
|
||||
className="flex-1 mr-2"
|
||||
|
@ -13,14 +13,18 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { atom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atomFamily, atomWithDefault } from "jotai/utils";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTransition } from "react";
|
||||
|
||||
import { currentBrowserSessionIdAtom } from "../atoms";
|
||||
import { graphql } from "../gql";
|
||||
import { PageInfo } from "../gql/graphql";
|
||||
import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination";
|
||||
import {
|
||||
atomForCurrentPagination,
|
||||
atomWithPagination,
|
||||
Pagination,
|
||||
} from "../pagination";
|
||||
|
||||
import BlockList from "./BlockList";
|
||||
import BrowserSession from "./BrowserSession";
|
||||
@ -62,15 +66,12 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
const currentPagination = atomWithDefault<Pagination>((get) => ({
|
||||
first: get(pageSizeAtom),
|
||||
after: null,
|
||||
}));
|
||||
const currentPaginationAtom = atomForCurrentPagination();
|
||||
|
||||
const browserSessionListFamily = atomFamily((userId: string) => {
|
||||
const browserSessionList = atomWithQuery({
|
||||
query: QUERY,
|
||||
getVariables: (get) => ({ userId, ...get(currentPagination) }),
|
||||
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
|
||||
});
|
||||
return browserSessionList;
|
||||
});
|
||||
@ -85,7 +86,7 @@ const pageInfoFamily = atomFamily((userId: string) => {
|
||||
|
||||
const paginationFamily = atomFamily((userId: string) => {
|
||||
const paginationAtom = atomWithPagination(
|
||||
currentPagination,
|
||||
currentPaginationAtom,
|
||||
pageInfoFamily(userId)
|
||||
);
|
||||
|
||||
@ -96,7 +97,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const currentSessionId = useAtomValue(currentBrowserSessionIdAtom);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const result = useAtomValue(browserSessionListFamily(userId));
|
||||
const setPagination = useSetAtom(currentPagination);
|
||||
const setPagination = useSetAtom(currentPaginationAtom);
|
||||
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
|
||||
|
||||
const paginate = (pagination: Pagination) => {
|
||||
|
@ -13,13 +13,17 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { atom, useSetAtom, useAtomValue } from "jotai";
|
||||
import { atomFamily, atomWithDefault } from "jotai/utils";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTransition } from "react";
|
||||
|
||||
import { graphql } from "../gql";
|
||||
import { PageInfo } from "../gql/graphql";
|
||||
import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination";
|
||||
import {
|
||||
atomForCurrentPagination,
|
||||
atomWithPagination,
|
||||
Pagination,
|
||||
} from "../pagination";
|
||||
|
||||
import BlockList from "./BlockList";
|
||||
import CompatSsoLogin from "./CompatSsoLogin";
|
||||
@ -60,15 +64,12 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
const currentPagination = atomWithDefault<Pagination>((get) => ({
|
||||
first: get(pageSizeAtom),
|
||||
after: null,
|
||||
}));
|
||||
const currentPaginationAtom = atomForCurrentPagination();
|
||||
|
||||
const compatSsoLoginListFamily = atomFamily((userId: string) => {
|
||||
const compatSsoLoginList = atomWithQuery({
|
||||
query: QUERY,
|
||||
getVariables: (get) => ({ userId, ...get(currentPagination) }),
|
||||
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
|
||||
});
|
||||
|
||||
return compatSsoLoginList;
|
||||
@ -85,7 +86,7 @@ const pageInfoFamily = atomFamily((userId: string) => {
|
||||
|
||||
const paginationFamily = atomFamily((userId: string) => {
|
||||
const paginationAtom = atomWithPagination(
|
||||
currentPagination,
|
||||
currentPaginationAtom,
|
||||
pageInfoFamily(userId)
|
||||
);
|
||||
return paginationAtom;
|
||||
@ -94,7 +95,7 @@ const paginationFamily = atomFamily((userId: string) => {
|
||||
const CompatSsoLoginList: React.FC<{ userId: string }> = ({ userId }) => {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const result = useAtomValue(compatSsoLoginListFamily(userId));
|
||||
const setPagination = useSetAtom(currentPagination);
|
||||
const setPagination = useSetAtom(currentPaginationAtom);
|
||||
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
|
||||
|
||||
const paginate = (pagination: Pagination) => {
|
||||
|
@ -13,13 +13,17 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { useAtomValue, atom, useSetAtom } from "jotai";
|
||||
import { atomFamily, atomWithDefault } from "jotai/utils";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTransition } from "react";
|
||||
|
||||
import { graphql } from "../gql";
|
||||
import { PageInfo } from "../gql/graphql";
|
||||
import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination";
|
||||
import {
|
||||
atomForCurrentPagination,
|
||||
atomWithPagination,
|
||||
Pagination,
|
||||
} from "../pagination";
|
||||
|
||||
import BlockList from "./BlockList";
|
||||
import OAuth2Session from "./OAuth2Session";
|
||||
@ -61,15 +65,12 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
const currentPagination = atomWithDefault<Pagination>((get) => ({
|
||||
first: get(pageSizeAtom),
|
||||
after: null,
|
||||
}));
|
||||
const currentPaginationAtom = atomForCurrentPagination();
|
||||
|
||||
const oauth2SessionListFamily = atomFamily((userId: string) => {
|
||||
const oauth2SessionList = atomWithQuery({
|
||||
query: QUERY,
|
||||
getVariables: (get) => ({ userId, ...get(currentPagination) }),
|
||||
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
|
||||
});
|
||||
|
||||
return oauth2SessionList;
|
||||
@ -86,7 +87,7 @@ const pageInfoFamily = atomFamily((userId: string) => {
|
||||
|
||||
const paginationFamily = atomFamily((userId: string) => {
|
||||
const paginationAtom = atomWithPagination(
|
||||
currentPagination,
|
||||
currentPaginationAtom,
|
||||
pageInfoFamily(userId)
|
||||
);
|
||||
return paginationAtom;
|
||||
@ -99,7 +100,7 @@ type Props = {
|
||||
const OAuth2SessionList: React.FC<Props> = ({ userId }) => {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const result = useAtomValue(oauth2SessionListFamily(userId));
|
||||
const setPagination = useSetAtom(currentPagination);
|
||||
const setPagination = useSetAtom(currentPaginationAtom);
|
||||
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
|
||||
|
||||
const paginate = (pagination: Pagination) => {
|
||||
|
@ -24,6 +24,7 @@ import Button from "./Button";
|
||||
import DateTime from "./DateTime";
|
||||
import Input from "./Input";
|
||||
import Typography, { Bold } from "./Typography";
|
||||
import { emailPageResultFamily } from "./UserEmailList";
|
||||
|
||||
const FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment UserEmail_email on UserEmail {
|
||||
@ -74,6 +75,18 @@ const RESEND_VERIFICATION_EMAIL_MUTATION = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation RemoveEmail($id: ID!) {
|
||||
removeEmail(input: { userEmailId: $id }) {
|
||||
status
|
||||
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const verifyEmailFamily = atomFamily((id: string) => {
|
||||
const verifyEmail = atomWithMutation(VERIFY_EMAIL_MUTATION);
|
||||
|
||||
@ -100,19 +113,35 @@ const resendVerificationEmailFamily = atomFamily((id: string) => {
|
||||
return resendVerificationEmailAtom;
|
||||
});
|
||||
|
||||
const removeEmailFamily = atomFamily((id: string) => {
|
||||
const removeEmail = atomWithMutation(REMOVE_EMAIL_MUTATION);
|
||||
|
||||
// A proxy atom which pre-sets the id variable in the mutation
|
||||
const removeEmailAtom = atom(
|
||||
(get) => get(removeEmail),
|
||||
(get, set) => set(removeEmail, { id })
|
||||
);
|
||||
|
||||
return removeEmailAtom;
|
||||
});
|
||||
|
||||
const UserEmail: React.FC<{
|
||||
email: FragmentType<typeof FRAGMENT>;
|
||||
userId: string; // XXX: Can we get this from the fragment?
|
||||
isPrimary?: boolean;
|
||||
highlight?: boolean;
|
||||
}> = ({ email, isPrimary, highlight }) => {
|
||||
}> = ({ email, userId, isPrimary, highlight }) => {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const data = useFragment(FRAGMENT, email);
|
||||
const [verifyEmailResult, verifyEmail] = useAtom(verifyEmailFamily(data.id));
|
||||
const [resendVerificationEmailResult, resendVerificationEmail] = useAtom(
|
||||
resendVerificationEmailFamily(data.id)
|
||||
);
|
||||
const resetEmailPage = useSetAtom(emailPageResultFamily(userId));
|
||||
const removeEmail = useSetAtom(removeEmailFamily(data.id));
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// XXX: we probably want those callbacks passed in props instead
|
||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@ -132,8 +161,20 @@ const UserEmail: React.FC<{
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveClick = () => {
|
||||
startTransition(() => {
|
||||
removeEmail().then(() => {
|
||||
// XXX: this forces a re-fetch of the user's emails,
|
||||
// but maybe it's not the right place to do this
|
||||
resetEmailPage();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const emailSent =
|
||||
resendVerificationEmailResult.data?.sendVerificationEmail.status === "SENT";
|
||||
const invalidCode =
|
||||
verifyEmailResult.data?.verifyEmail.status === "INVALID_CODE";
|
||||
|
||||
return (
|
||||
<Block highlight={highlight}>
|
||||
@ -142,9 +183,16 @@ const UserEmail: React.FC<{
|
||||
Primary
|
||||
</Typography>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<Typography variant="caption">
|
||||
<Bold>{data.email}</Bold>
|
||||
</Typography>
|
||||
{!isPrimary && (
|
||||
<Button disabled={pending} onClick={onRemoveClick} compact>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{data.confirmedAt ? (
|
||||
<Typography variant="micro">
|
||||
Verified <DateTime datetime={data.confirmedAt} />
|
||||
@ -162,6 +210,9 @@ const UserEmail: React.FC<{
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
{invalidCode && (
|
||||
<div className="col-span-2 text-alert font-bold">Invalid code</div>
|
||||
)}
|
||||
<Button type="submit" disabled={pending}>
|
||||
Submit
|
||||
</Button>
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { atom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atomFamily, atomWithDefault } from "jotai/utils";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomWithQuery } from "jotai-urql";
|
||||
import { useTransition } from "react";
|
||||
|
||||
@ -22,7 +22,6 @@ import { PageInfo } from "../gql/graphql";
|
||||
import {
|
||||
atomForCurrentPagination,
|
||||
atomWithPagination,
|
||||
pageSizeAtom,
|
||||
Pagination,
|
||||
} from "../pagination";
|
||||
|
||||
@ -143,6 +142,7 @@ const UserEmailList: React.FC<{
|
||||
{result.data?.user?.emails?.edges?.map((edge) => (
|
||||
<UserEmail
|
||||
email={edge.node}
|
||||
userId={userId}
|
||||
key={edge.cursor}
|
||||
isPrimary={primaryEmailId === edge.node.id}
|
||||
highlight={highlightedEmail === edge.node.id}
|
||||
|
@ -37,6 +37,8 @@ const documents = {
|
||||
types.VerifyEmailDocument,
|
||||
"\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n":
|
||||
types.ResendVerificationEmailDocument,
|
||||
"\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n":
|
||||
types.RemoveEmailDocument,
|
||||
"\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 query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n":
|
||||
@ -135,6 +137,12 @@ export function graphql(
|
||||
export function graphql(
|
||||
source: "\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"
|
||||
): (typeof documents)["\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\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 mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n"
|
||||
): (typeof documents)["\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\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.
|
||||
*/
|
||||
|
@ -40,11 +40,11 @@ export type AddEmailInput = {
|
||||
export type AddEmailPayload = {
|
||||
__typename?: "AddEmailPayload";
|
||||
/** The email address that was added */
|
||||
email: UserEmail;
|
||||
email?: Maybe<UserEmail>;
|
||||
/** Status of the operation */
|
||||
status: AddEmailStatus;
|
||||
/** The user to whom the email address was added */
|
||||
user: User;
|
||||
user?: Maybe<User>;
|
||||
};
|
||||
|
||||
/** The status of the `addEmail` mutation */
|
||||
@ -53,6 +53,8 @@ export enum AddEmailStatus {
|
||||
Added = "ADDED",
|
||||
/** The email address already exists */
|
||||
Exists = "EXISTS",
|
||||
/** The email address is invalid */
|
||||
Invalid = "INVALID",
|
||||
}
|
||||
|
||||
export type Anonymous = Node & {
|
||||
@ -178,6 +180,8 @@ export type Mutation = {
|
||||
__typename?: "Mutation";
|
||||
/** Add an email address to the specified user */
|
||||
addEmail: AddEmailPayload;
|
||||
/** Remove an email address */
|
||||
removeEmail: RemoveEmailPayload;
|
||||
/** Send a verification code for an email address */
|
||||
sendVerificationEmail: SendVerificationEmailPayload;
|
||||
/** Submit a verification code for an email address */
|
||||
@ -189,6 +193,11 @@ export type MutationAddEmailArgs = {
|
||||
input: AddEmailInput;
|
||||
};
|
||||
|
||||
/** The mutations root of the GraphQL interface. */
|
||||
export type MutationRemoveEmailArgs = {
|
||||
input: RemoveEmailInput;
|
||||
};
|
||||
|
||||
/** The mutations root of the GraphQL interface. */
|
||||
export type MutationSendVerificationEmailArgs = {
|
||||
input: SendVerificationEmailInput;
|
||||
@ -352,6 +361,33 @@ export type QueryUserEmailArgs = {
|
||||
id: Scalars["ID"];
|
||||
};
|
||||
|
||||
/** The input for the `removeEmail` mutation */
|
||||
export type RemoveEmailInput = {
|
||||
/** The ID of the email address to remove */
|
||||
userEmailId: Scalars["ID"];
|
||||
};
|
||||
|
||||
/** The payload of the `removeEmail` mutation */
|
||||
export type RemoveEmailPayload = {
|
||||
__typename?: "RemoveEmailPayload";
|
||||
/** The email address that was removed */
|
||||
email?: Maybe<UserEmail>;
|
||||
/** Status of the operation */
|
||||
status: RemoveEmailStatus;
|
||||
/** The user to whom the email address belonged */
|
||||
user?: Maybe<User>;
|
||||
};
|
||||
|
||||
/** The status of the `removeEmail` mutation */
|
||||
export enum RemoveEmailStatus {
|
||||
/** The email address was not found */
|
||||
NotFound = "NOT_FOUND",
|
||||
/** Can't remove the primary email address */
|
||||
Primary = "PRIMARY",
|
||||
/** The email address was removed */
|
||||
Removed = "REMOVED",
|
||||
}
|
||||
|
||||
/** The input for the `sendVerificationEmail` mutation */
|
||||
export type SendVerificationEmailInput = {
|
||||
/** The ID of the email address to verify */
|
||||
@ -607,9 +643,13 @@ export type AddEmailMutation = {
|
||||
addEmail: {
|
||||
__typename?: "AddEmailPayload";
|
||||
status: AddEmailStatus;
|
||||
email: { __typename?: "UserEmail"; id: string } & {
|
||||
" $fragmentRefs"?: { UserEmail_EmailFragment: UserEmail_EmailFragment };
|
||||
email?:
|
||||
| ({ __typename?: "UserEmail"; id: string } & {
|
||||
" $fragmentRefs"?: {
|
||||
UserEmail_EmailFragment: UserEmail_EmailFragment;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
};
|
||||
};
|
||||
|
||||
@ -808,6 +848,19 @@ export type ResendVerificationEmailMutation = {
|
||||
};
|
||||
};
|
||||
|
||||
export type RemoveEmailMutationVariables = Exact<{
|
||||
id: Scalars["ID"];
|
||||
}>;
|
||||
|
||||
export type RemoveEmailMutation = {
|
||||
__typename?: "Mutation";
|
||||
removeEmail: {
|
||||
__typename?: "RemoveEmailPayload";
|
||||
status: RemoveEmailStatus;
|
||||
user?: { __typename?: "User"; id: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserEmailListQueryQueryVariables = Exact<{
|
||||
userId: Scalars["ID"];
|
||||
first?: InputMaybe<Scalars["Int"]>;
|
||||
@ -2101,6 +2154,70 @@ export const ResendVerificationEmailDocument = {
|
||||
ResendVerificationEmailMutation,
|
||||
ResendVerificationEmailMutationVariables
|
||||
>;
|
||||
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<RemoveEmailMutation, RemoveEmailMutationVariables>;
|
||||
export const UserEmailListQueryDocument = {
|
||||
kind: "Document",
|
||||
definitions: [
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,11 @@ import { cacheExchange } from "@urql/exchange-graphcache";
|
||||
import { refocusExchange } from "@urql/exchange-refocus";
|
||||
import { requestPolicyExchange } from "@urql/exchange-request-policy";
|
||||
|
||||
import type { MutationAddEmailArgs } from "./gql/graphql";
|
||||
import type {
|
||||
MutationAddEmailArgs,
|
||||
MutationRemoveEmailArgs,
|
||||
RemoveEmailPayload,
|
||||
} from "./gql/graphql";
|
||||
import schema from "./gql/schema";
|
||||
|
||||
const cache = cacheExchange({
|
||||
@ -39,6 +43,36 @@ const cache = cacheExchange({
|
||||
cache.invalidate(key, field.fieldName, field.arguments);
|
||||
});
|
||||
},
|
||||
|
||||
removeEmail: (
|
||||
result: { removeEmail?: RemoveEmailPayload },
|
||||
args: MutationRemoveEmailArgs,
|
||||
cache,
|
||||
_info
|
||||
) => {
|
||||
// Invalidate the email entity
|
||||
cache.invalidate({
|
||||
__typename: "UserEmail",
|
||||
id: args.input.userEmailId,
|
||||
});
|
||||
|
||||
// Let's try to figure out the userId to invalidate the emails field on the User object
|
||||
const userId = result.removeEmail?.user?.id;
|
||||
if (userId) {
|
||||
const key = cache.keyOfEntity({
|
||||
__typename: "User",
|
||||
id: userId,
|
||||
});
|
||||
|
||||
// Invalidate the emails field on the User object so that it gets refetched
|
||||
cache
|
||||
.inspectFields(key)
|
||||
.filter((field) => field.fieldName === "emails")
|
||||
.forEach((field) => {
|
||||
cache.invalidate(key, field.fieldName, field.arguments);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user