1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

WIP my account page

This commit is contained in:
Quentin Gliech
2023-04-26 14:10:19 +02:00
parent d5bdf2ab15
commit 415d06f209
17 changed files with 4898 additions and 2244 deletions

View File

@ -10,10 +10,17 @@ const config: CodegenConfig = {
plugins: [], plugins: [],
}, },
"./src/gql/schema.ts": { "./src/gql/schema.ts": {
plugins: ["urql-introspection"], plugins: [
{
add: {
content: "/* eslint-disable */",
},
},
"urql-introspection",
],
}, },
}, },
hooks: { afterAllFileWrite: ["eslint --fix"] }, hooks: { afterOneFileWrite: ["prettier --write"] },
}; };
export default config; export default config;

File diff suppressed because it is too large Load Diff

View File

@ -16,11 +16,13 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@urql/core": "^4.0.6", "@emotion/react": "^11.10.6",
"@urql/exchange-graphcache": "^6.0.2", "@urql/core": "^4.0.7",
"@urql/exchange-graphcache": "^6.0.3",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"jotai": "^2.0.4", "jotai": "^2.0.4",
"jotai-devtools": "^0.4.0",
"jotai-location": "^0.5.1", "jotai-location": "^0.5.1",
"jotai-urql": "^0.7.1", "jotai-urql": "^0.7.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -28,38 +30,38 @@
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^3.3.1", "@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^3.0.0", "@graphql-codegen/client-preset": "^3.0.1",
"@graphql-codegen/urql-introspection": "^2.2.1", "@graphql-codegen/urql-introspection": "^2.2.1",
"@graphql-eslint/eslint-plugin": "^3.18.0", "@graphql-eslint/eslint-plugin": "^3.18.0",
"@storybook/addon-actions": "^7.0.6", "@storybook/addon-actions": "^7.0.7",
"@storybook/addon-backgrounds": "^7.0.6", "@storybook/addon-backgrounds": "^7.0.7",
"@storybook/addon-controls": "^7.0.6", "@storybook/addon-controls": "^7.0.7",
"@storybook/addon-docs": "^7.0.6", "@storybook/addon-docs": "^7.0.7",
"@storybook/addon-essentials": "^7.0.6", "@storybook/addon-essentials": "^7.0.7",
"@storybook/addon-measure": "^7.0.6", "@storybook/addon-measure": "^7.0.7",
"@storybook/addon-outline": "^7.0.6", "@storybook/addon-outline": "^7.0.7",
"@storybook/addon-toolbars": "^7.0.6", "@storybook/addon-toolbars": "^7.0.7",
"@storybook/addon-viewport": "^7.0.6", "@storybook/addon-viewport": "^7.0.7",
"@storybook/react": "^7.0.6", "@storybook/react": "^7.0.7",
"@storybook/react-vite": "^7.0.6", "@storybook/react-vite": "^7.0.7",
"@types/node": "^18.16.0", "@types/node": "^18.16.1",
"@types/react": "^18.0.37", "@types/react": "^18.2.0",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.2.1",
"@types/react-test-renderer": "^18.0.0", "@types/react-test-renderer": "^18.0.0",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"@vitest/coverage-c8": "^0.30.1", "@vitest/coverage-c8": "^0.30.1",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^8.38.0", "eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"postcss": "^8.4.23", "postcss": "^8.4.23",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"storybook": "^7.0.6", "storybook": "^7.0.7",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.2",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"vite": "^4.3.1", "vite": "^4.3.2",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-graphql-codegen": "^3.2.0", "vite-plugin-graphql-codegen": "^3.2.0",
"vitest": "^0.30.1" "vitest": "^0.30.1"

View File

@ -25,14 +25,14 @@ type Location = {
}; };
type HomeRoute = { type: "home" }; type HomeRoute = { type: "home" };
type DumbRoute = { type: "dumb" }; type AccountRoute = { type: "account" };
type OAuth2ClientRoute = { type: "client"; id: string }; type OAuth2ClientRoute = { type: "client"; id: string };
type BrowserSessionRoute = { type: "session"; id: string }; type BrowserSessionRoute = { type: "session"; id: string };
type UnknownRoute = { type: "unknown"; segments: string[] }; type UnknownRoute = { type: "unknown"; segments: string[] };
export type Route = export type Route =
| HomeRoute | HomeRoute
| DumbRoute | AccountRoute
| OAuth2ClientRoute | OAuth2ClientRoute
| BrowserSessionRoute | BrowserSessionRoute
| UnknownRoute; | UnknownRoute;
@ -41,8 +41,8 @@ const routeToSegments = (route: Route): string[] => {
switch (route.type) { switch (route.type) {
case "home": case "home":
return []; return [];
case "dumb": case "account":
return ["dumb"]; return ["account"];
case "client": case "client":
return ["client", route.id]; return ["client", route.id];
case "session": case "session":
@ -57,8 +57,8 @@ const segmentsToRoute = (segments: string[]): Route => {
return { type: "home" }; return { type: "home" };
} }
if (segments.length === 1 && segments[0] === "dumb") { if (segments.length === 1 && segments[0] === "account") {
return { type: "dumb" }; return { type: "account" };
} }
if (segments.length === 2 && segments[0] === "client") { if (segments.length === 2 && segments[0] === "client") {
@ -105,6 +105,7 @@ export const routeAtom = atom(
); );
const Home = lazy(() => import("./pages/Home")); const Home = lazy(() => import("./pages/Home"));
const Account = lazy(() => import("./pages/Account"));
const OAuth2Client = lazy(() => import("./pages/OAuth2Client")); const OAuth2Client = lazy(() => import("./pages/OAuth2Client"));
const BrowserSession = lazy(() => import("./pages/BrowserSession")); const BrowserSession = lazy(() => import("./pages/BrowserSession"));
@ -114,12 +115,12 @@ const InnerRouter: React.FC = () => {
switch (route.type) { switch (route.type) {
case "home": case "home":
return <Home />; return <Home />;
case "account":
return <Account />;
case "client": case "client":
return <OAuth2Client id={route.id} />; return <OAuth2Client id={route.id} />;
case "session": case "session":
return <BrowserSession id={route.id} />; return <BrowserSession id={route.id} />;
case "dumb":
return <>Dumb route.</>;
case "unknown": case "unknown":
return <>Unknown route {JSON.stringify(route.segments)}</>; return <>Unknown route {JSON.stringify(route.segments)}</>;
} }

View File

@ -0,0 +1,88 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React, { useRef, useTransition } from "react";
import { atomWithMutation } from "jotai-urql";
import { useAtom } from "jotai";
import { graphql } from "../gql";
import Button from "./Button";
import UserEmail from "./UserEmail";
import Input from "./Input";
import Typography from "./Typography";
const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation AddEmail($userId: ID!, $email: String!) {
addEmail(input: { userId: $userId, email: $email }) {
status
email {
id
...UserEmail_email
}
}
}
`);
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();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const email = e.currentTarget.email.value;
startTransition(() => {
addEmail({ userId, email }).then(() => {
if (formRef.current) {
formRef.current.reset();
}
});
});
};
return (
<>
{addEmailResult.data?.addEmail.status === "ADDED" && (
<>
<div className="p-4">
<Typography variant="subtitle">Email added!</Typography>
</div>
<UserEmail email={addEmailResult.data?.addEmail.email} />
</>
)}
{addEmailResult.data?.addEmail.status === "EXISTS" && (
<>
<div className="p-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"
name="email"
/>
<Button disabled={pending} type="submit">
Add
</Button>
</form>
</>
);
};
export default AddEmailForm;

View File

@ -19,11 +19,14 @@ type Props = {
/** Makes the button more compact */ /** Makes the button more compact */
compact?: boolean; compact?: boolean;
/** Uses the 'ghotst' (outline) alternative */ /** Uses the 'ghost' (outline) alternative */
ghost?: boolean; ghost?: boolean;
/** Disables all interactions with the button */ /** Disables all interactions with the button */
disabled?: boolean; disabled?: boolean;
/** The type of the button */
type?: "button" | "submit" | "reset";
} & React.HTMLProps<HTMLButtonElement>; } & React.HTMLProps<HTMLButtonElement>;
const Button: React.FC<Props> = ({ const Button: React.FC<Props> = ({
@ -31,12 +34,13 @@ const Button: React.FC<Props> = ({
compact, compact,
ghost, ghost,
disabled, disabled,
type,
...props ...props
}) => { }) => {
const sizeClass = compact ? "py-1 px-3" : "py-1 px-5"; const sizeClass = compact ? "py-1 px-3" : "py-1 px-5";
let ghostClass = ""; let ghostClass;
let normalClass = ""; let normalClass;
if (disabled) { if (disabled) {
ghostClass = "opacity-30 border border-accent text-accent"; ghostClass = "opacity-30 border border-accent text-accent";
@ -52,7 +56,7 @@ const Button: React.FC<Props> = ({
return ( return (
<button <button
{...props} {...props}
type="button" type={type || "button"}
className={`rounded-lg font-semibold ${colors} ${sizeClass}`} className={`rounded-lg font-semibold ${colors} ${sizeClass}`}
disabled={disabled} disabled={disabled}
> >

View File

@ -0,0 +1,50 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { Meta, StoryObj } from "@storybook/react";
import Input from "./Input";
const meta = {
title: "UI/Input",
component: Input,
tags: ["autodocs"],
argTypes: {
value: {
control: "text",
},
placeholder: {
control: "text",
},
disabled: {
control: "boolean",
},
},
args: {
value: "",
placeholder: "Placeholder",
disabled: false,
},
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof Input>;
export const Basic: Story = {};
export const Disabled: Story = {
args: {
disabled: true,
},
};

View File

@ -0,0 +1,28 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
type Props = {
disabled?: boolean;
className?: string;
} & React.HTMLProps<HTMLInputElement>;
const Input: React.FC<Props> = ({ disabled, className, ...props }) => {
const disabledClass = disabled
? "bg-grey-50 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}`;
return <input disabled={disabled} className={fullClassName} {...props} />;
};
export default Input;

View File

@ -20,7 +20,7 @@ const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
<div className="bg-grey-25 text-black-900 dark:bg-black-800 dark:text-white flex flex-col min-h-screen"> <div className="bg-grey-25 text-black-900 dark:bg-black-800 dark:text-white flex flex-col min-h-screen">
<NavBar className="mx-auto px-3 py-4 container"> <NavBar className="mx-auto px-3 py-4 container">
<NavItem route={{ type: "home" }}>Home</NavItem> <NavItem route={{ type: "home" }}>Home</NavItem>
<NavItem route={{ type: "dumb" }}>Dumb</NavItem> <NavItem route={{ type: "account" }}>My Account</NavItem>
</NavBar> </NavBar>
<main className="mx-auto p-4 container">{children}</main> <main className="mx-auto p-4 container">{children}</main>

View File

@ -0,0 +1,53 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { FragmentType, graphql, useFragment } from "../gql";
import React from "react";
import Block from "./Block";
import Typography, { Bold } from "./Typography";
import DateTime from "./DateTime";
const FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmail_email on UserEmail {
id
email
createdAt
confirmedAt
}
`);
const UserEmail: React.FC<{ email: FragmentType<typeof FRAGMENT> }> = ({
email,
}) => {
const data = useFragment(FRAGMENT, email);
return (
<Block>
<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>
)}
</Block>
);
};
export default UserEmail;

View File

@ -0,0 +1,108 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { atom, useAtomValue, useSetAtom } from "jotai";
import { atomWithQuery } from "jotai-urql";
import { atomFamily } from "jotai/utils";
import deepEqual from "fast-deep-equal";
import { graphql } from "../gql";
import { useTransition } from "react";
import Button from "./Button";
import UserEmail from "./UserEmail";
import BlockList from "./BlockList";
const QUERY = graphql(/* GraphQL */ `
query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {
user(id: $userId) {
id
emails(first: $first, after: $after) {
edges {
cursor
node {
id
...UserEmail_email
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`);
const emailPageResultFamily = atomFamily(
({ userId, after }: { userId: string; after: string | null }) =>
atomWithQuery({
query: QUERY,
getVariables: () => ({ userId, first: 5, after }),
}),
deepEqual
);
const emailPageListFamily = atomFamily((_userId: string) =>
atom([null as string | null])
);
const emailNextPageFamily = atomFamily((userId: string) =>
atom(null, (get, set, after: string) => {
const currentList = get(emailPageListFamily(userId));
set(emailPageListFamily(userId), [...currentList, after]);
})
);
const emailPageFamily = atomFamily((userId: string) =>
atom(async (get) => {
const list = get(emailPageListFamily(userId));
return await Promise.all(
list.map((after) => get(emailPageResultFamily({ userId, after })))
);
})
);
const UserEmailList: React.FC<{ userId: string }> = ({ userId }) => {
const [pending, startTransition] = useTransition();
const result = useAtomValue(emailPageFamily(userId));
const setLoadNextPage = useSetAtom(emailNextPageFamily(userId));
const endPageInfo = result[result.length - 1]?.data?.user?.emails?.pageInfo;
const loadNextPage = () => {
if (endPageInfo?.hasNextPage && endPageInfo.endCursor) {
const cursor = endPageInfo.endCursor;
startTransition(() => {
setLoadNextPage(cursor);
});
}
};
return (
<BlockList>
{result.flatMap(
(page) =>
page.data?.user?.emails?.edges?.map((edge) => (
<UserEmail email={edge.node} key={edge.cursor} />
)) || []
)}
{endPageInfo?.hasNextPage && (
<Button compact ghost onClick={loadNextPage}>
{pending ? "Loading..." : "Load more"}
</Button>
)}
</BlockList>
);
};
export default UserEmailList;

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,15 +13,34 @@ 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 fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n": types.BrowserSession_SessionFragmentDoc, "\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 BrowserSessionList_user on User {\n browserSessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n }\n }\n": types.BrowserSessionList_UserFragmentDoc, types.AddEmailDocument,
"\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 fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
"\n fragment CompatSsoLoginList_user on User {\n compatSsoLogins(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n": types.CompatSsoLoginList_UserFragmentDoc, types.BrowserSession_SessionFragmentDoc,
"\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 fragment BrowserSessionList_user on User {\n browserSessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n }\n }\n":
"\n fragment OAuth2SessionList_user on User {\n oauth2Sessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n": types.OAuth2SessionList_UserFragmentDoc, types.BrowserSessionList_UserFragmentDoc,
"\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 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 HomeQuery($count: Int!, $cursor: String) {\n # eslint-disable-next-line @graphql-eslint/no-deprecated\n currentBrowserSession {\n id\n user {\n id\n username\n\n ...CompatSsoLoginList_user\n ...BrowserSessionList_user\n ...OAuth2SessionList_user\n }\n }\n }\n": types.HomeQueryDocument, types.CompatSsoLogin_LoginFragmentDoc,
"\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 fragment CompatSsoLoginList_user on User {\n compatSsoLogins(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n":
types.CompatSsoLoginList_UserFragmentDoc,
"\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 fragment OAuth2SessionList_user on User {\n oauth2Sessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n":
types.OAuth2SessionList_UserFragmentDoc,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n":
types.UserEmail_EmailFragmentDoc,
"\n query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {\n user(id: $userId) {\n id\n emails(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n":
types.UserEmailListQueryDocument,
"\n query CurrentUserQuery {\n viewer {\n ... on User {\n __typename\n id\n }\n }\n }\n":
types.CurrentUserQueryDocument,
"\n query AccountQuery($id: ID!) {\n user(id: $id) {\n id\n username\n }\n }\n":
types.AccountQueryDocument,
"\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 HomeQuery($count: Int!, $cursor: String) {\n # eslint-disable-next-line @graphql-eslint/no-deprecated\n currentBrowserSession {\n id\n user {\n id\n username\n\n ...CompatSsoLoginList_user\n ...BrowserSessionList_user\n ...OAuth2SessionList_user\n }\n }\n }\n":
types.HomeQueryDocument,
"\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,
}; };
/** /**
@ -41,42 +60,91 @@ 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 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 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 BrowserSessionList_user on User {\n browserSessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n }\n }\n"): (typeof documents)["\n fragment BrowserSessionList_user on User {\n browserSessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\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 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 BrowserSessionList_user on User {\n browserSessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n }\n }\n"
): (typeof documents)["\n fragment BrowserSessionList_user on User {\n browserSessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\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 CompatSsoLoginList_user on User {\n compatSsoLogins(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n"): (typeof documents)["\n fragment CompatSsoLoginList_user on User {\n compatSsoLogins(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\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 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 CompatSsoLoginList_user on User {\n compatSsoLogins(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n"
): (typeof documents)["\n fragment CompatSsoLoginList_user on User {\n compatSsoLogins(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\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 OAuth2SessionList_user on User {\n oauth2Sessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n"): (typeof documents)["\n fragment OAuth2SessionList_user on User {\n oauth2Sessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\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 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 fragment OAuth2SessionList_user on User {\n oauth2Sessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n"
): (typeof documents)["\n fragment OAuth2SessionList_user on User {\n oauth2Sessions(first: $count, after: $cursor) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\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 HomeQuery($count: Int!, $cursor: String) {\n # eslint-disable-next-line @graphql-eslint/no-deprecated\n currentBrowserSession {\n id\n user {\n id\n username\n\n ...CompatSsoLoginList_user\n ...BrowserSessionList_user\n ...OAuth2SessionList_user\n }\n }\n }\n"): (typeof documents)["\n query HomeQuery($count: Int!, $cursor: String) {\n # eslint-disable-next-line @graphql-eslint/no-deprecated\n currentBrowserSession {\n id\n user {\n id\n username\n\n ...CompatSsoLoginList_user\n ...BrowserSessionList_user\n ...OAuth2SessionList_user\n }\n }\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 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 UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {\n user(id: $userId) {\n id\n emails(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n"
): (typeof documents)["\n query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {\n user(id: $userId) {\n id\n emails(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n pageInfo {\n hasNextPage\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 CurrentUserQuery {\n viewer {\n ... on User {\n __typename\n id\n }\n }\n }\n"
): (typeof documents)["\n query CurrentUserQuery {\n viewer {\n ... on User {\n __typename\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 AccountQuery($id: ID!) {\n user(id: $id) {\n id\n username\n }\n }\n"
): (typeof documents)["\n query AccountQuery($id: ID!) {\n user(id: $id) {\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 HomeQuery($count: Int!, $cursor: String) {\n # eslint-disable-next-line @graphql-eslint/no-deprecated\n currentBrowserSession {\n id\n user {\n id\n username\n\n ...CompatSsoLoginList_user\n ...BrowserSessionList_user\n ...OAuth2SessionList_user\n }\n }\n }\n"
): (typeof documents)["\n query HomeQuery($count: Int!, $cursor: String) {\n # eslint-disable-next-line @graphql-eslint/no-deprecated\n currentBrowserSession {\n id\n user {\n id\n username\n\n ...CompatSsoLoginList_user\n ...BrowserSessionList_user\n ...OAuth2SessionList_user\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 one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Provider } from "jotai"; import { Provider } from "jotai";
import { DevTools } from "jotai-devtools";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import Router from "./Router"; import Router from "./Router";
@ -23,6 +24,7 @@ import { HydrateAtoms } from "./atoms";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<Provider> <Provider>
<DevTools />
<HydrateAtoms> <HydrateAtoms>
<React.Suspense fallback={<LoadingScreen />}> <React.Suspense fallback={<LoadingScreen />}>
<Router /> <Router />

View File

@ -0,0 +1,76 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { graphql } from "../gql";
import UserEmailList from "../components/UserEmailList";
import { Title } from "../components/Typography";
import AddEmailForm from "../components/AddEmailForm";
const CURRENT_USER_QUERY = graphql(/* GraphQL */ `
query CurrentUserQuery {
viewer {
... on User {
__typename
id
}
}
}
`);
const currentUserAtom = atomWithQuery({ query: CURRENT_USER_QUERY });
const QUERY = graphql(/* GraphQL */ `
query AccountQuery($id: ID!) {
user(id: $id) {
id
username
}
}
`);
const accountAtomFamily = atomFamily((id: string) =>
atomWithQuery({ query: QUERY, getVariables: () => ({ id }) })
);
const UserAccount: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(accountAtomFamily(id));
return (
<div className="grid grid-cols-1 gap-4">
<Title>Hello {result.data?.user?.username}</Title>
<UserEmailList userId={id} />
<AddEmailForm userId={id} />
</div>
);
};
const CurrentUserAccount: React.FC = () => {
const result = useAtomValue(currentUserAtom);
if (result.data?.viewer?.__typename === "User") {
return (
<div className="w-96 mx-auto">
<UserAccount id={result.data.viewer.id} />
</div>
);
}
return <div className="w-96 mx-auto">Not logged in.</div>;
};
export default CurrentUserAccount;

View File

@ -27,10 +27,18 @@ export default defineConfig({
}, },
plugins: [ plugins: [
codegen(), codegen(),
react(), react({
babel: {
plugins: [
"jotai/babel/plugin-react-refresh",
"jotai/babel/plugin-debug-label",
],
},
}),
eslint({ eslint({
// Explicitly set the config file, else storybook gets confused // Explicitly set the config file, else storybook gets confused
overrideConfigFile: "./.eslintrc.cjs", overrideConfigFile: "./.eslintrc.cjs",
exclude: ["./src/gql/*"],
}), }),
], ],
server: { server: {