1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-11-24 23:01:05 +03:00

WIP: start replacing jotai-urql with urql

This commit is contained in:
Quentin Gliech
2024-02-09 20:33:30 +01:00
parent 3d90d0861c
commit 1b75b3b185
19 changed files with 192 additions and 354 deletions

View File

@@ -34,7 +34,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.0.5", "react-i18next": "^14.0.5",
"ua-parser-js": "^1.0.37" "ua-parser-js": "^1.0.37",
"urql": "^4.0.6"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^5.0.2", "@graphql-codegen/cli": "^5.0.2",
@@ -23549,6 +23550,18 @@
"integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==",
"dev": true "dev": true
}, },
"node_modules/urql": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/urql/-/urql-4.0.6.tgz",
"integrity": "sha512-meXJ2puOd64uCGKh7Fse2R7gPa8+ZpBOoA62jN7CPXXUt7SVZSdeXWSpB3HvlfzLUkEqsWbvshwrgeWRYNNGaQ==",
"dependencies": {
"@urql/core": "^4.2.0",
"wonka": "^6.3.2"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/use-callback-ref": { "node_modules/use-callback-ref": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.1.tgz", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.1.tgz",

View File

@@ -42,7 +42,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.0.5", "react-i18next": "^14.0.5",
"ua-parser-js": "^1.0.37" "ua-parser-js": "^1.0.37",
"urql": "^4.0.6"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^5.0.2", "@graphql-codegen/cli": "^5.0.2",

View File

@@ -15,8 +15,9 @@
import { AnyVariables, CombinedError, OperationContext } from "@urql/core"; import { AnyVariables, CombinedError, OperationContext } from "@urql/core";
import { atom, WritableAtom } from "jotai"; import { atom, WritableAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils"; import { useHydrateAtoms } from "jotai/utils";
import { AtomWithQuery, atomWithQuery, clientAtom } from "jotai-urql"; import { AtomWithQuery, clientAtom } from "jotai-urql";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { useQuery } from "urql";
import { graphql } from "./gql"; import { graphql } from "./gql";
import { client } from "./graphql"; import { client } from "./graphql";
@@ -73,51 +74,15 @@ const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
... on User { ... on User {
id id
} }
... on Anonymous {
id
}
} }
} }
`); `);
const currentViewerAtom = atomWithQuery({ query: CURRENT_VIEWER_QUERY }); export const useCurrentUserId = (): string | null => {
const [result] = useQuery({ query: CURRENT_VIEWER_QUERY });
export const currentUserIdAtom: GqlAtom<string | null> = mapQueryAtom( if (result.error) throw result.error;
currentViewerAtom, if (!result.data) throw new Error(); // Suspense mode is enabled
(data) => { return result.data.viewer.__typename === "User"
if (data.viewer.__typename === "User") { ? result.data.viewer.id
return data.viewer.id; : null;
} };
return null;
},
);
const CURRENT_VIEWER_SESSION_QUERY = graphql(/* GraphQL */ `
query CurrentViewerSessionQuery {
viewerSession {
__typename
... on BrowserSession {
id
}
... on Anonymous {
id
}
}
}
`);
const currentViewerSessionAtom = atomWithQuery({
query: CURRENT_VIEWER_SESSION_QUERY,
});
export const currentBrowserSessionIdAtom: GqlAtom<string | null> = mapQueryAtom(
currentViewerSessionAtom,
(data) => {
if (data.viewerSession.__typename === "BrowserSession") {
return data.viewerSession.id;
}
return null;
},
);

View File

@@ -18,7 +18,6 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useCallback } from "react"; import { useCallback } from "react";
import { currentBrowserSessionIdAtom, currentUserIdAtom } from "../atoms";
import { FragmentType, graphql, useFragment } from "../gql"; import { FragmentType, graphql, useFragment } from "../gql";
import { import {
parseUserAgent, parseUserAgent,
@@ -74,21 +73,12 @@ export const useEndBrowserSession = (
): (() => Promise<void>) => { ): (() => Promise<void>) => {
const endSession = useSetAtom(endBrowserSessionFamily(sessionId)); const endSession = useSetAtom(endBrowserSessionFamily(sessionId));
// Pull those atoms to reset them when the current session is ended
const currentUserId = useSetAtom(currentUserIdAtom);
const currentBrowserSessionId = useSetAtom(currentBrowserSessionIdAtom);
const onSessionEnd = useCallback(async (): Promise<void> => { const onSessionEnd = useCallback(async (): Promise<void> => {
await endSession(); await endSession();
if (isCurrent) { if (isCurrent) {
currentBrowserSessionId({ window.location.reload();
requestPolicy: "network-only",
});
currentUserId({
requestPolicy: "network-only",
});
} }
}, [isCurrent, endSession, currentBrowserSessionId, currentUserId]); }, [isCurrent, endSession]);
return onSessionEnd; return onSessionEnd;
}; };

View File

@@ -15,28 +15,17 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { describe, expect, it, vi, afterAll, beforeEach } from "vitest"; import { describe, expect, it } from "vitest";
import { currentUserIdAtom, GqlResult } from "../../atoms";
import { WithLocation } from "../../test-utils/WithLocation"; import { WithLocation } from "../../test-utils/WithLocation";
import Layout from "./Layout"; import Layout from "./Layout";
describe("<Layout />", () => { describe("<Layout />", () => {
beforeEach(() => {
vi.spyOn(currentUserIdAtom, "read").mockResolvedValue(
"abc123" as unknown as GqlResult<string | null>,
);
});
afterAll(() => {
vi.restoreAllMocks();
});
it("renders app navigation correctly", async () => { it("renders app navigation correctly", async () => {
const component = render( const component = render(
<WithLocation path="/"> <WithLocation path="/">
<Layout /> <Layout userId="abc123" />
</WithLocation>, </WithLocation>,
); );

View File

@@ -15,37 +15,25 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { currentUserIdAtom } from "../../atoms";
import { isErr, unwrapErr, unwrapOk } from "../../result";
import { appConfigAtom, routeAtom } from "../../routing"; import { appConfigAtom, routeAtom } from "../../routing";
import Footer from "../Footer"; import Footer from "../Footer";
import GraphQLError from "../GraphQLError";
import NavBar from "../NavBar"; import NavBar from "../NavBar";
import NavItem from "../NavItem"; import NavItem from "../NavItem";
import NotLoggedIn from "../NotLoggedIn";
import UserGreeting from "../UserGreeting"; import UserGreeting from "../UserGreeting";
import styles from "./Layout.module.css"; import styles from "./Layout.module.css";
const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const Layout: React.FC<{
userId: string;
children?: React.ReactNode;
}> = ({ userId, children }) => {
const route = useAtomValue(routeAtom); const route = useAtomValue(routeAtom);
const appConfig = useAtomValue(appConfigAtom); const appConfig = useAtomValue(appConfigAtom);
const result = useAtomValue(currentUserIdAtom);
const { t } = useTranslation(); const { t } = useTranslation();
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
// Hide the nav bar & user greeting on the verify-email page // Hide the nav bar & user greeting on the verify-email page
const shouldHideNavBar = route.type === "verify-email"; const shouldHideNavBar = route.type === "verify-email";
const userId = unwrapOk(result);
if (userId === null)
return (
<div className={styles.container}>
<NotLoggedIn />
</div>
);
return ( return (
<div className={styles.layoutContainer}> <div className={styles.layoutContainer}>
{shouldHideNavBar ? null : ( {shouldHideNavBar ? null : (

View File

@@ -13,10 +13,8 @@ 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": "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n":
types.CurrentViewerQueryDocument, 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 fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n": "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
types.BrowserSession_SessionFragmentDoc, types.BrowserSession_SessionFragmentDoc,
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n": "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n":
@@ -79,6 +77,8 @@ const documents = {
types.SessionsOverviewQueryDocument, types.SessionsOverviewQueryDocument,
"\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n": "\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n":
types.VerifyEmailQueryDocument, types.VerifyEmailQueryDocument,
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n":
types.CurrentViewerSessionQueryDocument,
}; };
/** /**
@@ -99,14 +99,8 @@ 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( 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", source: "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\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"]; ): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
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.
*/ */
@@ -293,6 +287,12 @@ export function graphql(
export function graphql( export function graphql(
source: "\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n", source: "\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n",
): (typeof documents)["\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n"]; ): (typeof documents)["\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\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 }\n",
): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n"];
export function graphql(source: string) { export function graphql(source: string) {
return (documents as any)[source] ?? {}; return (documents as any)[source] ?? {};

View File

@@ -1111,21 +1111,7 @@ export type CurrentViewerQueryQueryVariables = Exact<{ [key: string]: never }>;
export type CurrentViewerQueryQuery = { export type CurrentViewerQueryQuery = {
__typename?: "Query"; __typename?: "Query";
viewer: viewer: { __typename: "Anonymous" } | { __typename: "User"; id: string };
| { __typename: "Anonymous"; id: string }
| { __typename: "User"; id: string };
};
export type CurrentViewerSessionQueryQueryVariables = Exact<{
[key: string]: never;
}>;
export type CurrentViewerSessionQueryQuery = {
__typename?: "Query";
viewerSession:
| { __typename: "Anonymous"; id: string }
| { __typename: "BrowserSession"; id: string }
| { __typename: "Oauth2Session" };
}; };
export type BrowserSession_SessionFragment = { export type BrowserSession_SessionFragment = {
@@ -1680,6 +1666,18 @@ export type VerifyEmailQueryQuery = {
| null; | null;
}; };
export type CurrentViewerSessionQueryQueryVariables = Exact<{
[key: string]: never;
}>;
export type CurrentViewerSessionQueryQuery = {
__typename?: "Query";
viewerSession:
| { __typename: "Anonymous" }
| { __typename: "BrowserSession"; id: string }
| { __typename: "Oauth2Session" };
};
export const BrowserSession_SessionFragmentDoc = { export const BrowserSession_SessionFragmentDoc = {
kind: "Document", kind: "Document",
definitions: [ definitions: [
@@ -2090,19 +2088,6 @@ export const CurrentViewerQueryDocument = {
], ],
}, },
}, },
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "Anonymous" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
], ],
}, },
}, },
@@ -2114,60 +2099,6 @@ export const CurrentViewerQueryDocument = {
CurrentViewerQueryQuery, CurrentViewerQueryQuery,
CurrentViewerQueryQueryVariables CurrentViewerQueryQueryVariables
>; >;
export const CurrentViewerSessionQueryDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "CurrentViewerSessionQuery" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "viewerSession" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "__typename" } },
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "BrowserSession" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "Anonymous" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CurrentViewerSessionQueryQuery,
CurrentViewerSessionQueryQueryVariables
>;
export const EndBrowserSessionDocument = { export const EndBrowserSessionDocument = {
kind: "Document", kind: "Document",
definitions: [ definitions: [
@@ -4435,3 +4366,44 @@ export const VerifyEmailQueryDocument = {
VerifyEmailQueryQuery, VerifyEmailQueryQuery,
VerifyEmailQueryQueryVariables VerifyEmailQueryQueryVariables
>; >;
export const CurrentViewerSessionQueryDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "CurrentViewerSessionQuery" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "viewerSession" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "__typename" } },
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "BrowserSession" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CurrentViewerSessionQueryQuery,
CurrentViewerSessionQueryQueryVariables
>;

View File

@@ -132,6 +132,7 @@ const exchanges = [
export const client = createClient({ export const client = createClient({
url: appConfig.graphqlEndpoint, url: appConfig.graphqlEndpoint,
suspense: true,
// Add the devtools exchange in development // Add the devtools exchange in development
exchanges: import.meta.env.DEV ? [devtoolsExchange, ...exchanges] : exchanges, exchanges: import.meta.env.DEV ? [devtoolsExchange, ...exchanges] : exchanges,
}); });

View File

@@ -18,32 +18,49 @@ import { DevTools } from "jotai-devtools";
import { Suspense, StrictMode } from "react"; import { Suspense, StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Provider as UrqlProvider } from "urql";
import { HydrateAtoms } from "./atoms"; import { HydrateAtoms, useCurrentUserId } from "./atoms";
import ErrorBoundary from "./components/ErrorBoundary";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import LoadingSpinner from "./components/LoadingSpinner"; import LoadingSpinner from "./components/LoadingSpinner";
import NotLoggedIn from "./components/NotLoggedIn";
import { client } from "./graphql";
import i18n from "./i18n"; import i18n from "./i18n";
import { Router } from "./routing"; import { Router } from "./routing";
import "./main.css"; import "./main.css";
const App: React.FC = () => {
const userId = useCurrentUserId();
if (userId === null) return <NotLoggedIn />;
return (
<Layout userId={userId}>
<Suspense fallback={<LoadingSpinner />}>
<Router userId={userId} />
</Suspense>
</Layout>
);
};
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode> <StrictMode>
<ErrorBoundary>
<UrqlProvider value={client}>
<Provider> <Provider>
{import.meta.env.DEV && <DevTools />} {import.meta.env.DEV && <DevTools />}
<HydrateAtoms> <HydrateAtoms>
<Suspense fallback={<LoadingScreen />}> <Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<TooltipProvider> <TooltipProvider>
<Layout> <App />
<Suspense fallback={<LoadingSpinner />}>
<Router />
</Suspense>
</Layout>
</TooltipProvider> </TooltipProvider>
</I18nextProvider> </I18nextProvider>
</Suspense> </Suspense>
</HydrateAtoms> </HydrateAtoms>
</Provider> </Provider>
</UrqlProvider>
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );

View File

@@ -12,17 +12,13 @@
// 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 { useAtomValue } from "jotai"; import { useQuery } from "urql";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { mapQueryAtom } from "../atoms";
import ErrorBoundary from "../components/ErrorBoundary"; import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError"; import GraphQLError from "../components/GraphQLError";
import NotFound from "../components/NotFound"; import NotFound from "../components/NotFound";
import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail"; import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { isErr, unwrapErr, unwrapOk } from "../result";
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query BrowserSessionQuery($id: ID!) { query BrowserSessionQuery($id: ID!) {
@@ -33,25 +29,15 @@ const QUERY = graphql(/* GraphQL */ `
} }
`); `);
const browserSessionFamily = atomFamily((id: string) => {
const browserSessionQueryAtom = atomWithQuery({
query: QUERY,
getVariables: () => ({ id }),
});
const browserSessionAtom = mapQueryAtom(
browserSessionQueryAtom,
(data) => data?.browserSession,
);
return browserSessionAtom;
});
const BrowserSession: React.FC<{ id: string }> = ({ id }) => { const BrowserSession: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(browserSessionFamily(id)); const [result] = useQuery({
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; query: QUERY,
variables: { id },
});
if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
const browserSession = unwrapOk(result); const browserSession = result.data.browserSession;
if (!browserSession) return <NotFound />; if (!browserSession) return <NotFound />;
return ( return (

View File

@@ -12,27 +12,10 @@
// 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 { useAtomValue } from "jotai";
import { currentUserIdAtom } from "../atoms";
import List from "../components/BrowserSessionList"; import List from "../components/BrowserSessionList";
import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn";
import { isErr, unwrapErr, unwrapOk } from "../result";
const BrowserSessionList: React.FC = () => { const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
const result = useAtomValue(currentUserIdAtom); return <List userId={userId} />;
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
const userId = unwrapOk(result);
if (userId === null) return <NotLoggedIn />;
return (
<ErrorBoundary>
<List userId={userId} />
</ErrorBoundary>
);
}; };
export default BrowserSessionList; export default BrowserSessionList;

View File

@@ -12,17 +12,13 @@
// 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 { useAtomValue } from "jotai"; import { useQuery } from "urql";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { mapQueryAtom } from "../atoms";
import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail"; import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail";
import ErrorBoundary from "../components/ErrorBoundary"; import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError"; import GraphQLError from "../components/GraphQLError";
import NotFound from "../components/NotFound"; import NotFound from "../components/NotFound";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { isErr, unwrapErr, unwrapOk } from "../result";
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query OAuth2ClientQuery($id: ID!) { query OAuth2ClientQuery($id: ID!) {
@@ -32,25 +28,15 @@ const QUERY = graphql(/* GraphQL */ `
} }
`); `);
const oauth2ClientFamily = atomFamily((id: string) => {
const oauth2ClientQueryAtom = atomWithQuery({
query: QUERY,
getVariables: () => ({ id }),
});
const oauth2ClientAtom = mapQueryAtom(
oauth2ClientQueryAtom,
(data) => data?.oauth2Client,
);
return oauth2ClientAtom;
});
const OAuth2Client: React.FC<{ id: string }> = ({ id }) => { const OAuth2Client: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(oauth2ClientFamily(id)); const [result] = useQuery({
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; query: QUERY,
variables: { id },
});
if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
const oauth2Client = unwrapOk(result); const oauth2Client = result.data.oauth2Client;
if (!oauth2Client) return <NotFound />; if (!oauth2Client) return <NotFound />;
return ( return (

View File

@@ -12,22 +12,10 @@
// 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 { useAtomValue } from "jotai";
import { currentUserIdAtom } from "../atoms";
import ErrorBoundary from "../components/ErrorBoundary"; import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn";
import UserProfile from "../components/UserProfile"; import UserProfile from "../components/UserProfile";
import { isErr, unwrapErr, unwrapOk } from "../result";
const Profile: React.FC = () => {
const result = useAtomValue(currentUserIdAtom);
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
const userId = unwrapOk(result);
if (userId === null) return <NotLoggedIn />;
const Profile: React.FC<{ userId: string }> = ({ userId }) => {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<UserProfile userId={userId} /> <UserProfile userId={userId} />

View File

@@ -12,27 +12,13 @@
// 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 { useAtomValue } from "jotai";
import { currentUserIdAtom } from "../atoms";
import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn";
import UserSessionDetail from "../components/SessionDetail"; import UserSessionDetail from "../components/SessionDetail";
import { isErr, unwrapErr, unwrapOk } from "../result";
const SessionDetail: React.FC<{ deviceId: string }> = ({ deviceId }) => { const SessionDetail: React.FC<{ userId: string; deviceId: string }> = ({
const result = useAtomValue(currentUserIdAtom); userId,
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; deviceId,
}) => {
const userId = unwrapOk(result); return <UserSessionDetail userId={userId} deviceId={deviceId} />;
if (userId === null) return <NotLoggedIn />;
return (
<ErrorBoundary>
<UserSessionDetail userId={userId} deviceId={deviceId} />
</ErrorBoundary>
);
}; };
export default SessionDetail; export default SessionDetail;

View File

@@ -12,16 +12,13 @@
// 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 { useAtomValue } from "jotai"; import { useQuery } from "urql";
import { atomWithQuery } from "jotai-urql";
import { mapQueryAtom } from "../atoms";
import ErrorBoundary from "../components/ErrorBoundary"; import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError"; import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn"; import NotLoggedIn from "../components/NotLoggedIn";
import UserSessionsOverview from "../components/UserSessionsOverview"; import UserSessionsOverview from "../components/UserSessionsOverview";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { isErr, unwrapErr, unwrapOk } from "../result";
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query SessionsOverviewQuery { query SessionsOverviewQuery {
@@ -36,23 +33,13 @@ const QUERY = graphql(/* GraphQL */ `
} }
`); `);
const sessionsOverviewQueryAtom = atomWithQuery({
query: QUERY,
});
const sessionsOverviewAtom = mapQueryAtom(sessionsOverviewQueryAtom, (data) => {
if (data.viewer?.__typename === "User") {
return data.viewer;
}
return null;
});
const SessionsOverview: React.FC = () => { const SessionsOverview: React.FC = () => {
const result = useAtomValue(sessionsOverviewAtom); const [result] = useQuery({ query: QUERY });
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
const data = unwrapOk(result); const data =
result.data.viewer.__typename === "User" ? result.data.viewer : null;
if (data === null) return <NotLoggedIn />; if (data === null) return <NotLoggedIn />;
return ( return (

View File

@@ -12,17 +12,13 @@
// 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 { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { mapQueryAtom } from "../atoms";
import ErrorBoundary from "../components/ErrorBoundary"; import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError"; import GraphQLError from "../components/GraphQLError";
import VerifyEmailComponent from "../components/VerifyEmail"; import VerifyEmailComponent from "../components/VerifyEmail";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { isErr, unwrapErr, unwrapOk } from "../result";
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query VerifyEmailQuery($id: ID!) { query VerifyEmailQuery($id: ID!) {
@@ -32,27 +28,14 @@ const QUERY = graphql(/* GraphQL */ `
} }
`); `);
const verifyEmailFamily = atomFamily((id: string) => {
const verifyEmailQueryAtom = atomWithQuery({
query: QUERY,
getVariables: () => ({ id }),
});
const verifyEmailAtom = mapQueryAtom(
verifyEmailQueryAtom,
(data) => data?.userEmail,
);
return verifyEmailAtom;
});
const VerifyEmail: React.FC<{ id: string }> = ({ id }) => { const VerifyEmail: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(verifyEmailFamily(id)); const [result] = useQuery({ query: QUERY, variables: { id } });
const { t } = useTranslation(); const { t } = useTranslation();
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
const email = unwrapOk(result); const email = result.data.userEmail;
if (email == null) return <>{t("frontend.verify_email.unknown_email")}</>; if (email == null) return <>{t("frontend.verify_email.unknown_email")}</>;
return ( return (

View File

@@ -55,7 +55,7 @@ const unknownRoute = (route: never): never => {
throw new Error(`Invalid route: ${JSON.stringify(route)}`); throw new Error(`Invalid route: ${JSON.stringify(route)}`);
}; };
const Router: React.FC = () => { const Router: React.FC<{ userId: string }> = ({ userId }) => {
const [route, redirecting] = useRouteWithRedirect(); const [route, redirecting] = useRouteWithRedirect();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -65,13 +65,13 @@ const Router: React.FC = () => {
switch (route.type) { switch (route.type) {
case "profile": case "profile":
return <Profile />; return <Profile userId={userId} />;
case "sessions-overview": case "sessions-overview":
return <SessionsOverview />; return <SessionsOverview />;
case "session": case "session":
return <SessionDetail deviceId={route.id} />; return <SessionDetail userId={userId} deviceId={route.id} />;
case "browser-session-list": case "browser-session-list":
return <BrowserSessionList />; return <BrowserSessionList userId={userId} />;
case "client": case "client":
return <OAuth2Client id={route.id} />; return <OAuth2Client id={route.id} />;
case "browser-session": case "browser-session":

View File

@@ -12,11 +12,20 @@
// 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 { CombinedError } from "@urql/core"; import { useQuery } from "urql";
import { useAtomValue } from "jotai";
import { currentBrowserSessionIdAtom } from "../../atoms"; import { graphql } from "../../gql";
import { isOk, unwrapOk, unwrapErr, isErr } from "../../result";
const CURRENT_VIEWER_SESSION_QUERY = graphql(/* GraphQL */ `
query CurrentViewerSessionQuery {
viewerSession {
__typename
... on BrowserSession {
id
}
}
}
`);
/** /**
* Query the current browser session id * Query the current browser session id
@@ -24,16 +33,10 @@ import { isOk, unwrapOk, unwrapErr, isErr } from "../../result";
* throws error when error result * throws error when error result
*/ */
export const useCurrentBrowserSessionId = (): string | null => { export const useCurrentBrowserSessionId = (): string | null => {
const currentSessionIdResult = useAtomValue(currentBrowserSessionIdAtom); const [result] = useQuery({ query: CURRENT_VIEWER_SESSION_QUERY });
if (result.error) throw result.error;
if (isErr(currentSessionIdResult)) { if (!result.data) throw new Error(); // Suspense mode is enabled
// eslint-disable-next-line no-throw-literal return result.data.viewer.__typename === "User"
throw unwrapErr<CombinedError>(currentSessionIdResult) as Error; ? result.data.viewer.id
} : null;
if (isOk<string | null, unknown>(currentSessionIdResult)) {
return unwrapOk<string | null>(currentSessionIdResult);
}
return null;
}; };