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 { graphql } from "../gql";
import { LAST_PAGE } from "../pagination";
import Button from "./Button";
import Input from "./Input";
import Typography from "./Typography";
import UserEmail from "./UserEmail";
import { emailPageResultFamily } from "./UserEmailList";
import {
currentPaginationAtom,
emailPageResultFamily,
primaryEmailResultFamily,
} from "./UserEmailList";
const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
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 formRef = useRef<HTMLFormElement>(null);
const [addEmailResult, addEmail] = useAtom(addUserEmailAtom);
const [pending, startTransition] = useTransition();
// XXX: is this the right way to do this?
const refetchList = useSetAtom(emailPageResultFamily(userId));
const refetchPrimaryEmail = useSetAtom(primaryEmailResultFamily(userId));
const setCurrentPagination = useSetAtom(currentPaginationAtom);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const email = e.currentTarget.email.value;
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
startTransition(() => {
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();
if (formRef.current) {
formRef.current.reset();
}
refetchPrimaryEmail();
// Reset the form
formRef.current?.reset();
});
});
});
};
@@ -65,7 +81,6 @@ const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
<div className="pt-4">
<Typography variant="subtitle">Email added!</Typography>
</div>
<UserEmail email={addEmailResult.data?.addEmail.email} />
</>
)}
{addEmailResult.data?.addEmail.status === "EXISTS" && (
@@ -73,14 +88,14 @@ const AddEmailForm: React.FC<{ userId: string }> = ({ userId }) => {
<div className="pt-4">
<Typography variant="subtitle">Email already exists!</Typography>
</div>
<UserEmail email={addEmailResult.data?.addEmail.email} />
</>
)}
<form className="flex" onSubmit={handleSubmit} ref={formRef}>
<Input
className="flex-1 mr-2"
disabled={pending}
type="text"
type="email"
inputMode="email"
name="email"
/>
<Button disabled={pending} type="submit">

View File

@@ -14,11 +14,18 @@
type Props = {
children: React.ReactNode;
highlight?: boolean;
};
const Block: React.FC<Props> = ({ children }) => {
const Block: React.FC<Props> = ({ children, highlight }) => {
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}
</div>
);

View File

@@ -19,9 +19,9 @@ type Props = {
const Input: React.FC<Props> = ({ disabled, className, ...props }) => {
const disabledClass = disabled
? "bg-grey-50 dark:bg-grey-400"
? "bg-grey-100 dark:bg-grey-400"
: "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} />;
};

View File

@@ -12,10 +12,17 @@
// See the License for the specific language governing permissions and
// 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 Block from "./Block";
import Button from "./Button";
import DateTime from "./DateTime";
import Input from "./Input";
import Typography, { Bold } from "./Typography";
const FRAGMENT = graphql(/* GraphQL */ `
@@ -27,24 +34,141 @@ const FRAGMENT = graphql(/* GraphQL */ `
}
`);
const UserEmail: React.FC<{ email: FragmentType<typeof FRAGMENT> }> = ({
email,
}) => {
const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ `
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 [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 (
<Block>
<Block highlight={highlight}>
{isPrimary && (
<Typography variant="body" bold>
Primary
</Typography>
)}
<Typography variant="caption">
<Bold>{data.email}</Bold>
{data.confirmedAt ? "" : " (not verified)"}
</Typography>
{data.confirmedAt ? (
<Typography variant="micro">
Verified <DateTime datetime={data.confirmedAt} />
</Typography>
) : (
<Typography variant="micro">
Added <DateTime datetime={data.createdAt} />
</Typography>
<form
onSubmit={onFormSubmit}
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>
);

View File

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

View File

@@ -18,7 +18,7 @@ import { atomWithQuery } from "jotai-urql";
import { graphql } from "../gql";
import { Title } from "./Typography";
import Typography from "./Typography";
const QUERY = graphql(/* GraphQL */ `
query UserGreeting($userId: ID!) {
@@ -42,7 +42,11 @@ const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
const result = useAtomValue(userGreetingFamily(userId));
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</>;

View File

@@ -1,6 +1,6 @@
/* eslint-disable */
import * as types from './graphql';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
import * as types from "./graphql";
import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
/**
* 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.
*/
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 CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n": types.CurrentViewerSessionQueryDocument,
"\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 fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n": types.BrowserSession_SessionFragmentDoc,
"\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 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,
"\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 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 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 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,
"\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 CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n\n ... on Anonymous {\n id\n }\n }\n }\n":
types.CurrentViewerSessionQueryDocument,
"\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 fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
types.BrowserSession_SessionFragmentDoc,
"\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 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,
"\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 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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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) {
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 { currentUserIdAtom } from "../atoms";
import AddEmailForm from "../components/AddEmailForm";
import AddEmailForm, { addUserEmailAtom } from "../components/AddEmailForm";
import UserEmailList from "../components/UserEmailList";
import UserGreeting from "../components/UserGreeting";
const UserAccount: React.FC<{ id: string }> = ({ id }) => {
const addUserEmail = useAtomValue(addUserEmailAtom);
return (
<div className="grid grid-cols-1 gap-4">
<UserGreeting userId={id} />
<UserEmailList userId={id} />
<UserEmailList
userId={id}
highlightedEmail={addUserEmail.data?.addEmail?.email?.id}
/>
<AddEmailForm userId={id} />
</div>
);

View File

@@ -16,6 +16,10 @@ import { atom, Atom } from "jotai";
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 = {
first: number;
after: string | null;
@@ -45,6 +49,42 @@ export const isBackwardPagination = (
// This atom sets the default page size for pagination.
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
// next pagination objects, given the current pagination and the page info.
export const atomWithPagination = (