1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-11-20 12:02:22 +03:00

Nicer email management UI

This commit is contained in:
Quentin Gliech
2023-04-28 15:39:17 +02:00
parent aa8d5b6aed
commit e8b7591d7e
10 changed files with 2478 additions and 331 deletions

View File

@@ -17,12 +17,16 @@ import { atomWithMutation } from "jotai-urql";
import { useRef, useTransition } from "react"; import { useRef, useTransition } from "react";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { LAST_PAGE } from "../pagination";
import Button from "./Button"; import Button from "./Button";
import Input from "./Input"; import Input from "./Input";
import Typography from "./Typography"; import Typography from "./Typography";
import UserEmail from "./UserEmail"; import {
import { emailPageResultFamily } from "./UserEmailList"; currentPaginationAtom,
emailPageResultFamily,
primaryEmailResultFamily,
} from "./UserEmailList";
const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ ` const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation AddEmail($userId: ID!, $email: String!) { mutation AddEmail($userId: ID!, $email: String!) {
@@ -36,24 +40,36 @@ const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
} }
`); `);
const addUserEmailAtom = atomWithMutation(ADD_EMAIL_MUTATION); export const addUserEmailAtom = atomWithMutation(ADD_EMAIL_MUTATION);
const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => { const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [addEmailResult, addEmail] = useAtom(addUserEmailAtom); const [addEmailResult, addEmail] = useAtom(addUserEmailAtom);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
// XXX: is this the right way to do this? // XXX: is this the right way to do this?
const refetchList = useSetAtom(emailPageResultFamily(userId)); const refetchList = useSetAtom(emailPageResultFamily(userId));
const refetchPrimaryEmail = useSetAtom(primaryEmailResultFamily(userId));
const setCurrentPagination = useSetAtom(currentPaginationAtom);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const email = e.currentTarget.email.value;
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
startTransition(() => { startTransition(() => {
addEmail({ userId, email }).then(() => { addEmail({ userId, email }).then(() => {
startTransition(() => {
// Paginate to the last page
setCurrentPagination(LAST_PAGE);
// Make it refetch the list and the primary email, in case they changed
refetchList(); refetchList();
if (formRef.current) { refetchPrimaryEmail();
formRef.current.reset();
} // Reset the form
formRef.current?.reset();
});
}); });
}); });
}; };
@@ -65,7 +81,6 @@ const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
<div className="pt-4"> <div className="pt-4">
<Typography variant="subtitle">Email added!</Typography> <Typography variant="subtitle">Email added!</Typography>
</div> </div>
<UserEmail email={addEmailResult.data?.addEmail.email} />
</> </>
)} )}
{addEmailResult.data?.addEmail.status === "EXISTS" && ( {addEmailResult.data?.addEmail.status === "EXISTS" && (
@@ -73,14 +88,14 @@ const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
<div className="pt-4"> <div className="pt-4">
<Typography variant="subtitle">Email already exists!</Typography> <Typography variant="subtitle">Email already exists!</Typography>
</div> </div>
<UserEmail email={addEmailResult.data?.addEmail.email} />
</> </>
)} )}
<form className="flex" onSubmit={handleSubmit} ref={formRef}> <form className="flex" onSubmit={handleSubmit} ref={formRef}>
<Input <Input
className="flex-1 mr-2" className="flex-1 mr-2"
disabled={pending} disabled={pending}
type="text" type="email"
inputMode="email"
name="email" name="email"
/> />
<Button disabled={pending} type="submit"> <Button disabled={pending} type="submit">

View File

@@ -14,11 +14,18 @@
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
highlight?: boolean;
}; };
const Block: React.FC<Props> = ({ children }) => { const Block: React.FC<Props> = ({ children, highlight }) => {
return ( return (
<div className="p-4 bg-grey-50 dark:bg-grey-450 dark:text-white rounded"> <div
className={`p-4 dark:text-white rounded ${
highlight
? "border-2 border-grey-50 dark:border-grey-450 bg-white dark:bg-black"
: "bg-grey-50 dark:bg-grey-450"
}`}
>
{children} {children}
</div> </div>
); );

View File

@@ -19,9 +19,9 @@ type Props = {
const Input: React.FC<Props> = ({ disabled, className, ...props }) => { const Input: React.FC<Props> = ({ disabled, className, ...props }) => {
const disabledClass = disabled const disabledClass = disabled
? "bg-grey-50 dark:bg-grey-400" ? "bg-grey-100 dark:bg-grey-400"
: "bg-white dark:bg-grey-450"; : "bg-white dark:bg-grey-450";
const fullClassName = `${className} px-2 py-1 border-2 border-grey-50 dark:border-grey-400 dark:text-white placeholder-grey-100 dark:placeholder-grey-150 rounded-lg ${disabledClass}`; const fullClassName = `${className} px-2 py-1 border-2 border-grey-100 dark:border-grey-400 dark:text-white placeholder-grey-100 dark:placeholder-grey-150 rounded-lg ${disabledClass}`;
return <input disabled={disabled} className={fullClassName} {...props} />; return <input disabled={disabled} className={fullClassName} {...props} />;
}; };

View File

@@ -12,10 +12,17 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { atom, useAtom, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useRef, useTransition } from "react";
import { FragmentType, graphql, useFragment } from "../gql"; import { FragmentType, graphql, useFragment } from "../gql";
import Block from "./Block"; import Block from "./Block";
import Button from "./Button";
import DateTime from "./DateTime"; import DateTime from "./DateTime";
import Input from "./Input";
import Typography, { Bold } from "./Typography"; import Typography, { Bold } from "./Typography";
const FRAGMENT = graphql(/* GraphQL */ ` const FRAGMENT = graphql(/* GraphQL */ `
@@ -27,24 +34,141 @@ const FRAGMENT = graphql(/* GraphQL */ `
} }
`); `);
const UserEmail: React.FC<{ email: FragmentType<typeof FRAGMENT> }> = ({ const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ `
email, mutation VerifyEmail($id: ID!, $code: String!) {
}) => { verifyEmail(input: { userEmailId: $id, code: $code }) {
status
user {
id
primaryEmail {
id
}
}
email {
id
...UserEmail_email
}
}
}
`);
const RESEND_VERIFICATION_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation ResendVerificationEmail($id: ID!) {
sendVerificationEmail(input: { userEmailId: $id }) {
status
user {
id
primaryEmail {
id
}
}
email {
id
...UserEmail_email
}
}
}
`);
const verifyEmailFamily = atomFamily((id: string) => {
const verifyEmail = atomWithMutation(VERIFY_EMAIL_MUTATION);
// A proxy atom which pre-sets the id variable in the mutation
const verifyEmailAtom = atom(
(get) => get(verifyEmail),
(get, set, code: string) => set(verifyEmail, { id, code })
);
return verifyEmailAtom;
});
const resendVerificationEmailFamily = atomFamily((id: string) => {
const resendVerificationEmail = atomWithMutation(
RESEND_VERIFICATION_EMAIL_MUTATION
);
// A proxy atom which pre-sets the id variable in the mutation
const resendVerificationEmailAtom = atom(
(get) => get(resendVerificationEmail),
(get, set) => set(resendVerificationEmail, { id })
);
return resendVerificationEmailAtom;
});
const UserEmail: React.FC<{
email: FragmentType<typeof FRAGMENT>;
isPrimary?: boolean;
highlight?: boolean;
}> = ({ email, isPrimary, highlight }) => {
const [pending, startTransition] = useTransition();
const data = useFragment(FRAGMENT, email); const data = useFragment(FRAGMENT, email);
const [verifyEmailResult, verifyEmail] = useAtom(verifyEmailFamily(data.id));
const [resendVerificationEmailResult, resendVerificationEmail] = useAtom(
resendVerificationEmailFamily(data.id)
);
const formRef = useRef<HTMLFormElement>(null);
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const code = formData.get("code") as string;
startTransition(() => {
verifyEmail(code).then(() => {
formRef.current?.reset();
});
});
};
const onResendClick = () => {
startTransition(() => {
resendVerificationEmail().then(() => {
formRef.current?.code.focus();
});
});
};
const emailSent =
resendVerificationEmailResult.data?.sendVerificationEmail.status === "SENT";
return ( return (
<Block> <Block highlight={highlight}>
{isPrimary && (
<Typography variant="body" bold>
Primary
</Typography>
)}
<Typography variant="caption"> <Typography variant="caption">
<Bold>{data.email}</Bold> <Bold>{data.email}</Bold>
{data.confirmedAt ? "" : " (not verified)"}
</Typography> </Typography>
{data.confirmedAt ? ( {data.confirmedAt ? (
<Typography variant="micro"> <Typography variant="micro">
Verified <DateTime datetime={data.confirmedAt} /> Verified <DateTime datetime={data.confirmedAt} />
</Typography> </Typography>
) : ( ) : (
<Typography variant="micro"> <form
Added <DateTime datetime={data.createdAt} /> onSubmit={onFormSubmit}
</Typography> className="mt-2 grid grid-cols-2 gap-2"
ref={formRef}
>
<Input
className="col-span-2"
name="code"
placeholder="Code"
type="text"
inputMode="numeric"
/>
<Button type="submit" disabled={pending}>
Submit
</Button>
<Button disabled={pending || emailSent} onClick={onResendClick}>
{emailSent ? "Sent!" : "Resend"}
</Button>
</form>
)} )}
</Block> </Block>
); );

View File

@@ -19,7 +19,12 @@ import { useTransition } from "react";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { PageInfo } from "../gql/graphql"; import { PageInfo } from "../gql/graphql";
import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination"; import {
atomForCurrentPagination,
atomWithPagination,
pageSizeAtom,
Pagination,
} from "../pagination";
import BlockList from "./BlockList"; import BlockList from "./BlockList";
import PaginationControls from "./PaginationControls"; import PaginationControls from "./PaginationControls";
@@ -35,6 +40,7 @@ const QUERY = graphql(/* GraphQL */ `
) { ) {
user(id: $userId) { user(id: $userId) {
id id
emails(first: $first, after: $after, last: $last, before: $before) { emails(first: $first, after: $after, last: $last, before: $before) {
edges { edges {
cursor cursor
@@ -55,15 +61,40 @@ const QUERY = graphql(/* GraphQL */ `
} }
`); `);
const currentPagination = atomWithDefault<Pagination>((get) => ({ const PRIMARY_EMAIL_QUERY = graphql(/* GraphQL */ `
first: get(pageSizeAtom), query UserPrimaryEmail($userId: ID!) {
after: null, user(id: $userId) {
})); id
primaryEmail {
id
}
}
}
`);
export const primaryEmailResultFamily = atomFamily((userId: string) => {
const primaryEmailResult = atomWithQuery({
query: PRIMARY_EMAIL_QUERY,
getVariables: () => ({ userId }),
});
return primaryEmailResult;
});
const primaryEmailIdFamily = atomFamily((userId: string) => {
const primaryEmailIdAtom = atom(async (get) => {
const result = await get(primaryEmailResultFamily(userId));
return result.data?.user?.primaryEmail?.id ?? null;
});
return primaryEmailIdAtom;
});
export const currentPaginationAtom = atomForCurrentPagination();
export const emailPageResultFamily = atomFamily((userId: string) => { export const emailPageResultFamily = atomFamily((userId: string) => {
const emailPageResult = atomWithQuery({ const emailPageResult = atomWithQuery({
query: QUERY, query: QUERY,
getVariables: (get) => ({ userId, ...get(currentPagination) }), getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
}); });
return emailPageResult; return emailPageResult;
}); });
@@ -79,17 +110,21 @@ const pageInfoFamily = atomFamily((userId: string) => {
const paginationFamily = atomFamily((userId: string) => { const paginationFamily = atomFamily((userId: string) => {
const paginationAtom = atomWithPagination( const paginationAtom = atomWithPagination(
currentPagination, currentPaginationAtom,
pageInfoFamily(userId) pageInfoFamily(userId)
); );
return paginationAtom; return paginationAtom;
}); });
const UserEmailList: React.FC<{ userId: string }> = ({ userId }) => { const UserEmailList: React.FC<{
userId: string;
highlightedEmail?: string;
}> = ({ userId, highlightedEmail }) => {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const result = useAtomValue(emailPageResultFamily(userId)); const result = useAtomValue(emailPageResultFamily(userId));
const setPagination = useSetAtom(currentPagination); const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
const primaryEmailId = useAtomValue(primaryEmailIdFamily(userId));
const paginate = (pagination: Pagination) => { const paginate = (pagination: Pagination) => {
startTransition(() => { startTransition(() => {
@@ -106,7 +141,12 @@ const UserEmailList: React.FC<{ userId: string }> = ({ userId }) => {
disabled={pending} disabled={pending}
/> />
{result.data?.user?.emails?.edges?.map((edge) => ( {result.data?.user?.emails?.edges?.map((edge) => (
<UserEmail email={edge.node} key={edge.cursor} /> <UserEmail
email={edge.node}
key={edge.cursor}
isPrimary={primaryEmailId === edge.node.id}
highlight={highlightedEmail === edge.node.id}
/>
))} ))}
</BlockList> </BlockList>
); );

View File

@@ -18,7 +18,7 @@ import { atomWithQuery } from "jotai-urql";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { Title } from "./Typography"; import Typography from "./Typography";
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query UserGreeting($userId: ID!) { query UserGreeting($userId: ID!) {
@@ -42,7 +42,11 @@ const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
const result = useAtomValue(userGreetingFamily(userId)); const result = useAtomValue(userGreetingFamily(userId));
if (result.data?.user) { if (result.data?.user) {
return <Title>Hello, {result.data.user.username}!</Title>; return (
<Typography variant="headline">
Hello, {result.data.user.username}!
</Typography>
);
} }
return <>Failed to load user</>; return <>Failed to load user</>;

View File

@@ -1,6 +1,6 @@
/* eslint-disable */ /* eslint-disable */
import * as types from './graphql'; import * as types from "./graphql";
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
/** /**
* Map of all GraphQL operations in the project. * Map of all GraphQL operations in the project.
@@ -13,20 +13,40 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/
* Therefore it is highly recommended to use the babel or swc plugin for production. * Therefore it is highly recommended to use the babel or swc plugin for production.
*/ */
const documents = { const documents = {
"\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n": types.CurrentViewerQueryDocument, "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n":
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n": types.CurrentViewerSessionQueryDocument, types.CurrentViewerQueryDocument,
"\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.AddEmailDocument, "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n":
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n": types.BrowserSession_SessionFragmentDoc, types.CurrentViewerSessionQueryDocument,
"\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.BrowserSessionListDocument, "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n email {\n id\n ...UserEmail_email\n }\n }\n }\n":
"\n fragment CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n": types.CompatSsoLogin_LoginFragmentDoc, types.AddEmailDocument,
"\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.CompatSsoLoginListDocument, "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, types.BrowserSession_SessionFragmentDoc,
"\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.OAuth2SessionListQueryDocument, "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
"\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n": types.UserEmail_EmailFragmentDoc, types.BrowserSessionListDocument,
"\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 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 CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n":
"\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n }\n }\n": types.UserGreetingDocument, types.CompatSsoLogin_LoginFragmentDoc,
"\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n": types.BrowserSessionQueryDocument, "\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
"\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n id\n clientId\n clientName\n clientUri\n tosUri\n policyUri\n redirectUris\n }\n }\n": types.OAuth2ClientQueryDocument, types.CompatSsoLoginListDocument,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n":
types.OAuth2Session_SessionFragmentDoc,
"\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
types.OAuth2SessionListQueryDocument,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n":
types.UserEmail_EmailFragmentDoc,
"\n mutation VerifyEmail($id: ID!, $code: String!) {\n verifyEmail(input: { userEmailId: $id, code: $code }) {\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.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 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":
types.UserPrimaryEmailDocument,
"\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n }\n }\n":
types.UserGreetingDocument,
"\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n":
types.BrowserSessionQueryDocument,
"\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n id\n clientId\n clientName\n clientUri\n tosUri\n policyUri\n redirectUris\n }\n }\n":
types.OAuth2ClientQueryDocument,
}; };
/** /**
@@ -46,62 +66,109 @@ export function graphql(source: string): unknown;
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n"): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n"]; export function graphql(
source: "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n"
): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n\n ... on Anonymous {\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. * 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 CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n"): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n"]; export function graphql(
source: "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n"
): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\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. * 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 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 email {\n id\n ...UserEmail_email\n }\n }\n }\n"]; export function graphql(
source: "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\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 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. * 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 BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"): (typeof documents)["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"]; export function graphql(
source: "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"
): (typeof documents)["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"): (typeof documents)["\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; export function graphql(
source: "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"
): (typeof documents)["\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n"): (typeof documents)["\n fragment CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n"]; export function graphql(
source: "\n fragment CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n"
): (typeof documents)["\n fragment CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"): (typeof documents)["\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; export function graphql(
source: "\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"
): (typeof documents)["\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n"): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n"]; export function graphql(
source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n"
): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"): (typeof documents)["\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; export function graphql(
source: "\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"
): (typeof documents)["\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n"): (typeof documents)["\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n"]; export function graphql(
source: "\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n"
): (typeof documents)["\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\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"): (typeof documents)["\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 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"]; export function graphql(
source: "\n mutation VerifyEmail($id: ID!, $code: String!) {\n verifyEmail(input: { userEmailId: $id, code: $code }) {\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 VerifyEmail($id: ID!, $code: String!) {\n verifyEmail(input: { userEmailId: $id, code: $code }) {\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. * 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 UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n }\n }\n"): (typeof documents)["\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n }\n }\n"]; 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. * 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 BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n"): (typeof documents)["\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n"]; export function graphql(
source: "\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"
): (typeof documents)["\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"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n id\n clientId\n clientName\n clientUri\n tosUri\n policyUri\n redirectUris\n }\n }\n"): (typeof documents)["\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n id\n clientId\n clientName\n clientUri\n tosUri\n policyUri\n redirectUris\n }\n }\n"]; export function graphql(
source: "\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n"
): (typeof documents)["\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\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 query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n }\n }\n"
): (typeof documents)["\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\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 BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n }\n"
): (typeof documents)["\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\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 OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n id\n clientId\n clientName\n clientUri\n tosUri\n policyUri\n redirectUris\n }\n }\n"
): (typeof documents)["\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n id\n clientId\n clientName\n clientUri\n tosUri\n policyUri\n redirectUris\n }\n }\n"];
export function graphql(source: string) { export function graphql(source: string) {
return (documents as any)[source] ?? {}; return (documents as any)[source] ?? {};
} }
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;

File diff suppressed because it is too large Load Diff

View File

@@ -15,15 +15,19 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { currentUserIdAtom } from "../atoms"; import { currentUserIdAtom } from "../atoms";
import AddEmailForm from "../components/AddEmailForm"; import AddEmailForm, { addUserEmailAtom } from "../components/AddEmailForm";
import UserEmailList from "../components/UserEmailList"; import UserEmailList from "../components/UserEmailList";
import UserGreeting from "../components/UserGreeting"; import UserGreeting from "../components/UserGreeting";
const UserAccount: React.FC<{ id: string }> = ({ id }) => { const UserAccount: React.FC<{ id: string }> = ({ id }) => {
const addUserEmail = useAtomValue(addUserEmailAtom);
return ( return (
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<UserGreeting userId={id} /> <UserGreeting userId={id} />
<UserEmailList userId={id} /> <UserEmailList
userId={id}
highlightedEmail={addUserEmail.data?.addEmail?.email?.id}
/>
<AddEmailForm userId={id} /> <AddEmailForm userId={id} />
</div> </div>
); );

View File

@@ -16,6 +16,10 @@ import { atom, Atom } from "jotai";
import { PageInfo } from "./gql/graphql"; import { PageInfo } from "./gql/graphql";
export const FIRST_PAGE = Symbol("FIRST_PAGE");
export const LAST_PAGE = Symbol("LAST_PAGE");
const EMPTY = Symbol("EMPTY");
export type ForwardPagination = { export type ForwardPagination = {
first: number; first: number;
after: string | null; after: string | null;
@@ -45,6 +49,42 @@ export const isBackwardPagination = (
// This atom sets the default page size for pagination. // This atom sets the default page size for pagination.
export const pageSizeAtom = atom(6); export const pageSizeAtom = atom(6);
export const atomForCurrentPagination = () => {
const dataAtom = atom<typeof EMPTY | Pagination>(EMPTY);
const currentPaginationAtom = atom(
(get) => {
const data = get(dataAtom);
if (data === EMPTY) {
return {
first: get(pageSizeAtom),
after: null,
};
}
return data;
},
(get, set, action: Pagination | typeof FIRST_PAGE | typeof LAST_PAGE) => {
if (action === FIRST_PAGE) {
set(dataAtom, EMPTY);
} else if (action === LAST_PAGE) {
set(dataAtom, {
last: get(pageSizeAtom),
before: null,
});
} else {
set(dataAtom, action);
}
}
);
currentPaginationAtom.onMount = (setAtom) => {
setAtom(FIRST_PAGE);
};
return currentPaginationAtom;
};
// This atom is used to create a pagination atom that gives the previous and // This atom is used to create a pagination atom that gives the previous and
// next pagination objects, given the current pagination and the page info. // next pagination objects, given the current pagination and the page info.
export const atomWithPagination = ( export const atomWithPagination = (