Switch to Tanstack Query

This commit is contained in:
Quentin Gliech
2024-11-12 11:03:31 +01:00
parent 86df1fe2d0
commit 32adf83949
44 changed files with 715 additions and 535 deletions

View File

@@ -17,19 +17,24 @@
"crates/**",
"frontend/src/gql/**",
"frontend/src/routeTree.gen.ts",
"frontend/.storybook/locales.ts",
"frontend/locales/*.json",
"**/coverage/**",
"**/dist/**"
]
},
"formatter": {
"enabled": true,
"useEditorconfig": true,
"ignore": ["frontend/.storybook/locales.ts", "frontend/locales/*.json"]
"useEditorconfig": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"correctness": {
"noUnusedImports": "warn",
"noUnusedVariables": "warn"
}
}
}
}

View File

@@ -74,7 +74,7 @@ const withThemeProvider: Decorator = (Story, context) => {
);
};
const withDummyRouter: Decorator = (Story, context) => {
const withDummyRouter: Decorator = (Story, _context) => {
return (
<DummyRouter>
<Story />
@@ -82,7 +82,7 @@ const withDummyRouter: Decorator = (Story, context) => {
);
};
const withTooltipProvider: Decorator = (Story, context) => {
const withTooltipProvider: Decorator = (Story, _context) => {
return (
<TooltipProvider>
<Story />

View File

@@ -18,6 +18,7 @@ const config: CodegenConfig = {
enumsAsTypes: true,
// By default, unknown scalars are generated as `any`. This is not ideal for catching potential bugs.
defaultScalarType: "unknown",
maybeValue: "T | null | undefined",
scalars: {
DateTime: "string",
Url: "string",

View File

@@ -12,6 +12,7 @@
"@fontsource/inter": "^5.1.0",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@tanstack/react-query": "^5.59.20",
"@tanstack/react-router": "^1.81.5",
"@tanstack/router-zod-adapter": "^1.81.5",
"@urql/core": "^5.0.8",
@@ -25,6 +26,7 @@
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
"i18next": "^23.16.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -47,6 +49,7 @@
"@storybook/addon-essentials": "^8.4.3",
"@storybook/react": "^8.4.3",
"@storybook/react-vite": "^8.4.3",
"@tanstack/react-query-devtools": "^5.59.20",
"@tanstack/router-devtools": "^1.81.5",
"@tanstack/router-vite-plugin": "^1.79.0",
"@testing-library/react": "^16.0.1",
@@ -2566,6 +2569,20 @@
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@graphql-tools/prisma-loader/node_modules/graphql-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz",
"integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"cross-fetch": "^3.1.5"
},
"peerDependencies": {
"graphql": "14 - 16"
}
},
"node_modules/@graphql-tools/relay-operation-optimizer": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.2.tgz",
@@ -2673,7 +2690,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
@@ -5080,6 +5096,61 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.59.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.20.tgz",
"integrity": "sha512-e8vw0lf7KwfGe1if4uPFhvZRWULqHjFcz3K8AebtieXvnMOz5FSzlZe3mTLlPuUBcydCnBRqYs2YJ5ys68wwLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.59.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.59.20.tgz",
"integrity": "sha512-vxhuQ+8VV4YWQSFxQLsuM+dnEKRY7VeRzpNabFXdhEwsBYLrjXlF1pM38A8WyKNLqZy8JjyRO8oP4Wd/oKHwuQ==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.59.20",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.20.tgz",
"integrity": "sha512-Zly0egsK0tFdfSbh5/mapSa+Zfc3Et0Zkar7Wo5sQkFzWyB3p3uZWOHR2wrlAEEV2L953eLuDBtbgFvMYiLvUw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.59.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.59.20",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.59.20.tgz",
"integrity": "sha512-AL/eQS1NFZhwwzq2Bq9Gd8wTTH+XhPNOJlDFpzPMu9NC5CQVgA0J8lWrte/sXpdWNo5KA4hgHnEdImZsF4h6Lw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.59.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.59.20",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-router": {
"version": "1.81.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.81.5.tgz",
@@ -8494,14 +8565,12 @@
}
},
"node_modules/graphql-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz",
"integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==",
"dev": true,
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.1.2.tgz",
"integrity": "sha512-+XE3iuC55C2di5ZUrB4pjgwe+nIQBuXVIK9J98wrVwojzDW3GMdSBZfxUk8l4j9TieIpjpggclxhNEU9ebGF8w==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"cross-fetch": "^3.1.5"
"@graphql-typed-document-node/core": "^3.2.0"
},
"peerDependencies": {
"graphql": "14 - 16"

View File

@@ -21,6 +21,7 @@
"@fontsource/inter": "^5.1.0",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@tanstack/react-query": "^5.59.20",
"@tanstack/react-router": "^1.81.5",
"@tanstack/router-zod-adapter": "^1.81.5",
"@urql/core": "^5.0.8",
@@ -34,6 +35,7 @@
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
"i18next": "^23.16.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -56,6 +58,7 @@
"@storybook/addon-essentials": "^8.4.3",
"@storybook/react": "^8.4.3",
"@storybook/react-vite": "^8.4.3",
"@tanstack/react-query-devtools": "^5.59.20",
"@tanstack/router-devtools": "^1.81.5",
"@tanstack/router-vite-plugin": "^1.79.0",
"@testing-library/react": "^16.0.1",

View File

@@ -11,11 +11,11 @@ import { Badge } from "@vector-im/compound-web";
import { parseISO } from "date-fns";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../gql";
import type { DeviceType } from "../gql/graphql";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { graphqlClient } from "../graphql";
import DateTime from "./DateTime";
import EndSessionButton from "./Session/EndSessionButton";
import LastActive from "./Session/LastActive";
@@ -58,14 +58,26 @@ export const useEndBrowserSession = (
sessionId: string,
isCurrent: boolean,
): (() => Promise<void>) => {
const [, endSession] = useMutation(END_SESSION_MUTATION);
const queryClient = useQueryClient();
const endSession = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(END_SESSION_MUTATION, { id }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
queryClient.invalidateQueries({ queryKey: ["browserSessionList"] });
queryClient.invalidateQueries({
queryKey: ["sessionDetail", data.endBrowserSession.browserSession?.id],
});
if (isCurrent) {
window.location.reload();
}
},
});
const onSessionEnd = useCallback(async (): Promise<void> => {
await endSession({ id: sessionId });
if (isCurrent) {
window.location.reload();
}
}, [isCurrent, endSession, sessionId]);
await endSession.mutateAsync(sessionId);
}, [endSession.mutateAsync, sessionId]);
return onSessionEnd;
};

View File

@@ -4,13 +4,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../gql";
import { DeviceType } from "../gql/graphql";
import { graphqlClient } from "../graphql";
import { browserLogoUri } from "./BrowserSession";
import DateTime from "./DateTime";
import EndSessionButton from "./Session/EndSessionButton";
@@ -44,7 +42,6 @@ export const END_SESSION_MUTATION = graphql(/* GraphQL */ `
status
compatSession {
id
finishedAt
}
}
}
@@ -54,7 +51,7 @@ export const simplifyUrl = (url: string): string => {
let parsed: URL;
try {
parsed = new URL(url);
} catch (e) {
} catch (_e) {
// Not a valid URL, return the original
return url;
}
@@ -76,10 +73,21 @@ const CompatSession: React.FC<{
}> = ({ session }) => {
const { t } = useTranslation();
const data = useFragment(FRAGMENT, session);
const [, endCompatSession] = useMutation(END_SESSION_MUTATION);
const queryClient = useQueryClient();
const endSession = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(END_SESSION_MUTATION, { id }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
queryClient.invalidateQueries({ queryKey: ["appSessionList"] });
queryClient.invalidateQueries({
queryKey: ["sessionDetail", data.endCompatSession.compatSession?.id],
});
},
});
const onSessionEnd = async (): Promise<void> => {
await endCompatSession({ id: data.id });
await endSession.mutateAsync(data.id);
};
const clientName = data.ssoLogin?.redirectUri

View File

@@ -6,11 +6,12 @@
import cx from "classnames";
import { Suspense } from "react";
import { useQuery } from "urql";
import { graphql } from "../../gql";
import Footer from "../Footer";
import { queryOptions, useQuery } from "@tanstack/react-query";
import { graphqlClient } from "../../graphql";
import styles from "./Layout.module.css";
const QUERY = graphql(/* GraphQL */ `
@@ -22,10 +23,13 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = queryOptions({
queryKey: ["footer"],
queryFn: ({ signal }) => graphqlClient.request({ document: QUERY, signal }),
});
const AsyncFooter: React.FC = () => {
const [result] = useQuery({
query: QUERY,
});
const result = useQuery(query);
if (result.error) {
// We probably prefer to render an empty footer in case of an error

View File

@@ -4,4 +4,4 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
export { default } from "./Layout";
export { default, query } from "./Layout";

View File

@@ -4,14 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../gql";
import type { DeviceType, Oauth2ApplicationType } from "../gql/graphql";
import { graphqlClient } from "../graphql";
import { getDeviceIdFromScope } from "../utils/deviceIdFromScope";
import DateTime from "./DateTime";
import EndSessionButton from "./Session/EndSessionButton";
import LastActive from "./Session/LastActive";
@@ -49,7 +48,6 @@ export const END_SESSION_MUTATION = graphql(/* GraphQL */ `
status
oauth2Session {
id
...OAuth2Session_session
}
}
}
@@ -74,10 +72,21 @@ type Props = {
const OAuth2Session: React.FC<Props> = ({ session }) => {
const { t } = useTranslation();
const data = useFragment(FRAGMENT, session);
const [, endSession] = useMutation(END_SESSION_MUTATION);
const queryClient = useQueryClient();
const endSession = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(END_SESSION_MUTATION, { id }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
queryClient.invalidateQueries({ queryKey: ["appSessionList"] });
queryClient.invalidateQueries({
queryKey: ["sessionDetail", data.endOauth2Session.oauth2Session?.id],
});
},
});
const onSessionEnd = async (): Promise<void> => {
await endSession({ id: data.id });
await endSession.mutateAsync(data.id);
};
const deviceId = getDeviceIdFromScope(data.scope);

View File

@@ -6,8 +6,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DeviceType } from "../../gql/graphql";
import DeviceTypeIcon from "./DeviceTypeIcon";
const meta = {

View File

@@ -4,17 +4,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlClient } from "../../graphql";
import BlockList from "../BlockList/BlockList";
import { END_SESSION_MUTATION, simplifyUrl } from "../CompatSession";
import DateTime from "../DateTime";
import ExternalLink from "../ExternalLink/ExternalLink";
import EndSessionButton from "../Session/EndSessionButton";
import SessionDetails from "./SessionDetails";
import SessionHeader from "./SessionHeader";
@@ -44,11 +43,22 @@ type Props = {
const CompatSessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session);
const [, endSession] = useMutation(END_SESSION_MUTATION);
const queryClient = useQueryClient();
const endSession = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(END_SESSION_MUTATION, { id }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
queryClient.invalidateQueries({ queryKey: ["appSessionList"] });
queryClient.invalidateQueries({
queryKey: ["sessionDetail", data.endCompatSession.compatSession?.id],
});
},
});
const { t } = useTranslation();
const onSessionEnd = async (): Promise<void> => {
await endSession({ id: data.id });
await endSession.mutateAsync(data.id);
};
const finishedAt = data.finishedAt

View File

@@ -4,11 +4,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlClient } from "../../graphql";
import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope";
import BlockList from "../BlockList/BlockList";
import DateTime from "../DateTime";
@@ -16,7 +16,6 @@ import { Link } from "../Link";
import { END_SESSION_MUTATION } from "../OAuth2Session";
import ClientAvatar from "../Session/ClientAvatar";
import EndSessionButton from "../Session/EndSessionButton";
import SessionDetails from "./SessionDetails";
import SessionHeader from "./SessionHeader";
@@ -44,11 +43,23 @@ type Props = {
const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session);
const [, endSession] = useMutation(END_SESSION_MUTATION);
const queryClient = useQueryClient();
const endSession = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(END_SESSION_MUTATION, { id }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
queryClient.invalidateQueries({ queryKey: ["appSessionList"] });
queryClient.invalidateQueries({
queryKey: ["sessionDetail", data.endOauth2Session.oauth2Session?.id],
});
},
});
const { t } = useTranslation();
const onSessionEnd = async (): Promise<void> => {
await endSession({ id: data.id });
await endSession.mutateAsync(data.id);
};
const deviceId = getDeviceIdFromScope(data.scope);

View File

@@ -9,12 +9,12 @@ import IconEmail from "@vector-im/compound-design-tokens/assets/web/icons/email"
import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web";
import type { ComponentProps, ReactNode } from "react";
import { Translation, useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { Close, Description, Dialog, Title } from "../Dialog";
import { Link } from "../Link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { graphqlClient } from "../../graphql";
import styles from "./UserEmail.module.css";
// This component shows a single user email address, with controls to verify it,
@@ -132,24 +132,33 @@ const UserEmail: React.FC<{
const { t } = useTranslation();
const data = useFragment(FRAGMENT, email);
const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig);
const queryClient = useQueryClient();
const [setPrimaryResult, setPrimary] = useMutation(
SET_PRIMARY_EMAIL_MUTATION,
);
const [removeResult, removeEmail] = useMutation(REMOVE_EMAIL_MUTATION);
// Handle errors with the error boundary
if (setPrimaryResult.error) throw setPrimaryResult.error;
if (removeResult.error) throw removeResult.error;
const setPrimary = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(SET_PRIMARY_EMAIL_MUTATION, { id }),
onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
},
});
const removeEmail = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(REMOVE_EMAIL_MUTATION, { id }),
onSuccess: (_data) => {
onRemove?.();
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
},
});
const onRemoveClick = (): void => {
removeEmail({ id: data.id }).then(() => {
// Call the onRemove callback if provided
onRemove?.();
});
removeEmail.mutate(data.id);
};
const onSetPrimaryClick = (): void => {
setPrimary({ id: data.id });
setPrimary.mutate(data.id);
};
return (
@@ -171,7 +180,7 @@ const UserEmail: React.FC<{
{!isPrimary && emailChangeAllowed && (
<DeleteButtonWithConfirmation
email={data.email}
disabled={removeResult.fetching}
disabled={removeEmail.isPending}
onClick={onRemoveClick}
/>
)}
@@ -188,7 +197,7 @@ const UserEmail: React.FC<{
<button
type="button"
className={styles.link}
disabled={setPrimaryResult.fetching}
disabled={setPrimary.isPending}
onClick={onSetPrimaryClick}
>
{t("frontend.user_email.make_primary_button")}

View File

@@ -4,6 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import IconClose from "@vector-im/compound-design-tokens/assets/web/icons/close";
import IconEdit from "@vector-im/compound-design-tokens/assets/web/icons/edit";
import {
@@ -21,13 +22,10 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { SetDisplayNameStatus } from "../../gql/graphql";
import { graphqlClient } from "../../graphql";
import * as Dialog from "../Dialog";
import LoadingSpinner from "../LoadingSpinner";
import styles from "./UserGreeting.module.css";
export const FRAGMENT = graphql(/* GraphQL */ `
@@ -88,28 +86,32 @@ const UserGreeting: React.FC<Props> = ({ user, siteConfig }) => {
const fieldRef = useRef<HTMLInputElement>(null);
const data = useFragment(FRAGMENT, user);
const { displayNameChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig);
const queryClient = useQueryClient();
const [setDisplayNameResult, setDisplayName] = useMutation(
SET_DISPLAYNAME_MUTATION,
);
const setDisplayName = useMutation({
mutationFn: ({
userId,
displayName,
}: { userId: string; displayName: string | null }) =>
graphqlClient.request(SET_DISPLAYNAME_MUTATION, { userId, displayName }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
if (data.setDisplayName.status === "SET") {
setOpen(false);
}
},
});
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const onSubmit = async (
event: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
const onSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const displayName = (formData.get("displayname") as string) || null;
const result = await setDisplayName({ displayName, userId: data.id });
if (result.data?.setDisplayName.status === "SET") {
setOpen(false);
}
setDisplayName.mutate({ displayName, userId: data.id });
};
return (
@@ -163,7 +165,7 @@ const UserGreeting: React.FC<Props> = ({ user, siteConfig }) => {
<Form.Field
name="displayname"
serverInvalid={
setDisplayNameResult.data?.setDisplayName.status === "INVALID"
setDisplayName.data?.setDisplayName.status === "INVALID"
}
>
<Form.Label>
@@ -198,8 +200,8 @@ const UserGreeting: React.FC<Props> = ({ user, siteConfig }) => {
</Form.Field>
</div>
<Form.Submit disabled={setDisplayNameResult.fetching}>
{setDisplayNameResult.fetching && <LoadingSpinner inline />}
<Form.Submit disabled={setDisplayName.isPending}>
{setDisplayName.isPending && <LoadingSpinner inline />}
{t("action.save")}
</Form.Submit>
</Form.Root>

View File

@@ -4,15 +4,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
EditInPlace,
ErrorMessage,
HelpMessage,
} from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { graphql } from "../../gql";
import { graphqlClient } from "../../graphql";
const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation AddEmail($userId: ID!, $email: String!) {
@@ -32,8 +32,26 @@ const AddEmailForm: React.FC<{
onAdd: (id: string) => Promise<void>;
}> = ({ userId, onAdd }) => {
const { t } = useTranslation();
const [addEmailResult, addEmail] = useMutation(ADD_EMAIL_MUTATION);
if (addEmailResult.error) throw addEmailResult.error;
const queryClient = useQueryClient();
const addEmail = useMutation({
mutationFn: ({ userId, email }: { userId: string; email: string }) =>
graphqlClient.request(ADD_EMAIL_MUTATION, { userId, email }),
onSuccess: async (data) => {
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
// Don't clear the form if the email was invalid or already exists
if (data.addEmail.status !== "ADDED") {
return;
}
if (!data.addEmail.email?.id) {
throw new Error("Unexpected response from server");
}
// Call the onAdd callback
await onAdd(data.addEmail.email?.id);
},
});
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
@@ -42,23 +60,11 @@ const AddEmailForm: React.FC<{
const formData = new FormData(e.currentTarget);
const email = formData.get("input") as string;
const result = await addEmail({ userId, email });
// Don't clear the form if the email was invalid or already exists
if (result.data?.addEmail.status !== "ADDED") {
return;
}
if (!result.data?.addEmail.email?.id) {
throw new Error("Unexpected response from server");
}
// Call the onAdd callback
await onAdd(result.data?.addEmail.email?.id);
addEmail.mutate({ userId, email });
};
const status = addEmailResult.data?.addEmail.status ?? null;
const violations = addEmailResult.data?.addEmail.violations ?? [];
const status = addEmail.data?.addEmail.status ?? null;
const violations = addEmail.data?.addEmail.violations ?? [];
return (
<EditInPlace

View File

@@ -4,11 +4,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTransition } from "react";
import { useQuery } from "urql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlClient } from "../../graphql";
import {
type AnyPagination,
FIRST_PAGE,
type Pagination,
usePages,
@@ -73,13 +74,20 @@ const UserEmailList: React.FC<{
const [pending, startTransition] = useTransition();
const [pagination, setPagination] = usePagination();
const [result, refreshList] = useQuery({
query: QUERY,
variables: { userId: data.id, ...pagination },
const result = useSuspenseQuery({
queryKey: ["userEmails", pagination],
queryFn: ({ signal }) =>
graphqlClient.request({
document: QUERY,
variables: {
userId: data.id,
...(pagination as AnyPagination),
},
signal,
}),
});
if (result.error) throw result.error;
const emails = result.data?.user?.emails;
if (!emails) throw new Error(); // Suspense mode is enabled
const emails = result.data.user?.emails;
if (!emails) throw new Error();
const [prevPage, nextPage] = usePages(pagination, emails.pageInfo);
@@ -91,11 +99,10 @@ const UserEmailList: React.FC<{
});
};
// When removing an email, we want to refresh the list and go back to the first page
// When removing an email, we want to go back to the first page
const onRemove = (): void => {
startTransition(() => {
setPagination(FIRST_PAGE);
refreshList();
});
};

View File

@@ -4,16 +4,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLinkProps, useNavigate } from "@tanstack/react-router";
import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left";
import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send-solid";
import { Alert, Button, Form, H1, Text } from "@vector-im/compound-web";
import { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlClient } from "../../graphql";
import styles from "./VerifyEmail.module.css";
const FRAGMENT = graphql(/* GraphQL */ `
@@ -78,10 +77,28 @@ const VerifyEmail: React.FC<{
email: FragmentType<typeof FRAGMENT>;
}> = ({ email }) => {
const data = useFragment(FRAGMENT, email);
const [verifyEmailResult, verifyEmail] = useMutation(VERIFY_EMAIL_MUTATION);
const [resendVerificationEmailResult, resendVerificationEmail] = useMutation(
RESEND_VERIFICATION_EMAIL_MUTATION,
);
const queryClient = useQueryClient();
const verifyEmail = useMutation({
mutationFn: ({ id, code }: { id: string; code: string }) =>
graphqlClient.request(VERIFY_EMAIL_MUTATION, { id, code }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
queryClient.invalidateQueries({ queryKey: ["userProfile"] });
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
if (data.verifyEmail.status === "VERIFIED") {
navigate({ to: "/" });
}
},
});
const resendVerificationEmail = useMutation({
mutationFn: (id: string) =>
graphqlClient.request(RESEND_VERIFICATION_EMAIL_MUTATION, { id }),
onSuccess: () => {
fieldRef.current?.focus();
},
});
const navigate = useNavigate();
const fieldRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
@@ -91,26 +108,16 @@ const VerifyEmail: React.FC<{
const form = e.currentTarget;
const formData = new FormData(form);
const code = formData.get("code") as string;
verifyEmail({ id: data.id, code }).then((result) => {
// Clear the form
form.reset();
if (result.data?.verifyEmail.status === "VERIFIED") {
navigate({ to: "/" });
}
});
verifyEmail.mutateAsync({ id: data.id, code }).finally(() => form.reset());
};
const onResendClick = (): void => {
resendVerificationEmail({ id: data.id }).then(() => {
fieldRef.current?.focus();
});
resendVerificationEmail.mutate(data.id);
};
const emailSent =
resendVerificationEmailResult.data?.sendVerificationEmail.status === "SENT";
const invalidCode =
verifyEmailResult.data?.verifyEmail.status === "INVALID_CODE";
resendVerificationEmail.data?.sendVerificationEmail.status === "SENT";
const invalidCode = verifyEmail.data?.verifyEmail.status === "INVALID_CODE";
const { email: codeEmail } = data;
return (
@@ -163,13 +170,13 @@ const VerifyEmail: React.FC<{
</Form.ErrorMessage>
</Form.Field>
<Form.Submit type="submit" disabled={verifyEmailResult.fetching}>
<Form.Submit type="submit" disabled={verifyEmail.isPending}>
{t("action.continue")}
</Form.Submit>
<Button
type="button"
kind="secondary"
disabled={resendVerificationEmailResult.fetching}
disabled={resendVerificationEmail.isPending}
onClick={onResendClick}
>
{t("frontend.verify_email.resend_code")}

View File

@@ -19,11 +19,11 @@ const documents = {
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n": types.EndBrowserSessionDocument,
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": types.OAuth2Client_DetailFragmentDoc,
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_SessionFragmentDoc,
"\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n": types.EndCompatSessionDocument,
"\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n }\n }\n }\n": types.EndCompatSessionDocument,
"\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": types.Footer_SiteConfigFragmentDoc,
"\n query FooterQuery {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": types.FooterQueryDocument,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc,
"\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n": types.EndOAuth2SessionDocument,
"\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": types.EndOAuth2SessionDocument,
"\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": types.PasswordCreationDoubleInput_SiteConfigFragmentDoc,
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": types.BrowserSession_DetailFragmentDoc,
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc,
@@ -98,7 +98,7 @@ export function graphql(source: "\n fragment CompatSession_session on CompatSes
/**
* 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 EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n"): (typeof documents)["\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n"];
export function graphql(source: "\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\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.
*/
@@ -114,7 +114,7 @@ export function graphql(source: "\n fragment OAuth2Session_session on Oauth2Ses
/**
* 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 EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n"): (typeof documents)["\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n"];
export function graphql(source: "\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\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.
*/

View File

@@ -1439,7 +1439,7 @@ export type EndCompatSessionMutationVariables = Exact<{
}>;
export type EndCompatSessionMutation = { __typename?: 'Mutation', endCompatSession: { __typename?: 'EndCompatSessionPayload', status: EndCompatSessionStatus, compatSession?: { __typename?: 'CompatSession', id: string, finishedAt?: string | null } | null } };
export type EndCompatSessionMutation = { __typename?: 'Mutation', endCompatSession: { __typename?: 'EndCompatSessionPayload', status: EndCompatSessionStatus, compatSession?: { __typename?: 'CompatSession', id: string } | null } };
export type Footer_SiteConfigFragment = { __typename?: 'SiteConfig', id: string, imprint?: string | null, tosUri?: string | null, policyUri?: string | null } & { ' $fragmentName'?: 'Footer_SiteConfigFragment' };
@@ -1458,10 +1458,7 @@ export type EndOAuth2SessionMutationVariables = Exact<{
}>;
export type EndOAuth2SessionMutation = { __typename?: 'Mutation', endOauth2Session: { __typename?: 'EndOAuth2SessionPayload', status: EndOAuth2SessionStatus, oauth2Session?: (
{ __typename?: 'Oauth2Session', id: string }
& { ' $fragmentRefs'?: { 'OAuth2Session_SessionFragment': OAuth2Session_SessionFragment } }
) | null } };
export type EndOAuth2SessionMutation = { __typename?: 'Mutation', endOauth2Session: { __typename?: 'EndOAuth2SessionPayload', status: EndOAuth2SessionStatus, oauth2Session?: { __typename?: 'Oauth2Session', id: string } | null } };
export type PasswordCreationDoubleInput_SiteConfigFragment = { __typename?: 'SiteConfig', id: string, minimumPasswordComplexity: number } & { ' $fragmentName'?: 'PasswordCreationDoubleInput_SiteConfigFragment' };
@@ -1733,9 +1730,9 @@ export const UserEmailList_SiteConfigFragmentDoc = {"kind":"Document","definitio
export const BrowserSessionsOverview_UserFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BrowserSessionsOverview_user"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"browserSessions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"0"}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"EnumValue","value":"ACTIVE"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode<BrowserSessionsOverview_UserFragment, unknown>;
export const UserEmail_VerifyEmailFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_verifyEmail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]} as unknown as DocumentNode<UserEmail_VerifyEmailFragment, unknown>;
export const EndBrowserSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndBrowserSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endBrowserSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"browserSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"browserSession"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"BrowserSession_session"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BrowserSession_session"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BrowserSession"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"raw"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"os"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"deviceType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveIp"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastAuthentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<EndBrowserSessionMutation, EndBrowserSessionMutationVariables>;
export const EndCompatSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndCompatSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCompatSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"compatSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"compatSession"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}}]}}]}}]}}]} as unknown as DocumentNode<EndCompatSessionMutation, EndCompatSessionMutationVariables>;
export const EndCompatSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndCompatSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCompatSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"compatSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"compatSession"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode<EndCompatSessionMutation, EndCompatSessionMutationVariables>;
export const FooterQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FooterQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"siteConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"Footer_siteConfig"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Footer_siteConfig"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SiteConfig"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imprint"}},{"kind":"Field","name":{"kind":"Name","value":"tosUri"}},{"kind":"Field","name":{"kind":"Name","value":"policyUri"}}]}}]} as unknown as DocumentNode<FooterQueryQuery, FooterQueryQueryVariables>;
export const EndOAuth2SessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndOAuth2Session"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endOauth2Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"oauth2SessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"oauth2Session"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"OAuth2Session_session"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OAuth2Session_session"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Oauth2Session"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"scope"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveIp"}},{"kind":"Field","name":{"kind":"Name","value":"lastActiveAt"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"os"}},{"kind":"Field","name":{"kind":"Name","value":"deviceType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"clientName"}},{"kind":"Field","name":{"kind":"Name","value":"applicationType"}},{"kind":"Field","name":{"kind":"Name","value":"logoUri"}}]}}]}}]} as unknown as DocumentNode<EndOAuth2SessionMutation, EndOAuth2SessionMutationVariables>;
export const EndOAuth2SessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndOAuth2Session"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endOauth2Session"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"oauth2SessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"oauth2Session"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode<EndOAuth2SessionMutation, EndOAuth2SessionMutationVariables>;
export const RemoveEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userEmailId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode<RemoveEmailMutation, RemoveEmailMutationVariables>;
export const SetPrimaryEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetPrimaryEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setPrimaryEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userEmailId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"primaryEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]} as unknown as DocumentNode<SetPrimaryEmailMutation, SetPrimaryEmailMutationVariables>;
export const SetDisplayNameDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetDisplayName"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"displayName"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setDisplayName"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"displayName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"displayName"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"matrix"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]}}]}}]} as unknown as DocumentNode<SetDisplayNameMutation, SetDisplayNameMutationVariables>;

View File

@@ -4,32 +4,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { cacheExchange, createClient, fetchExchange } from "@urql/core";
import { devtoolsExchange } from "@urql/devtools";
import { refocusExchange } from "@urql/exchange-refocus";
import { requestPolicyExchange } from "@urql/exchange-request-policy";
import { GraphQLClient } from "graphql-request";
import appConfig from "./config";
const exchanges = [
// This sets the policy to 'cache-and-network' after 5 minutes
requestPolicyExchange({
ttl: 1000 * 60 * 5, // 5 minute
}),
// This refetches all queries when the tab is refocused
refocusExchange(),
// Simple cache
cacheExchange,
// Use `fetch` to execute the requests
fetchExchange,
];
export const client = createClient({
url: appConfig.graphqlEndpoint,
suspense: true,
// Add the devtools exchange in development
exchanges: import.meta.env.DEV ? [devtoolsExchange, ...exchanges] : exchanges,
});
export const graphqlClient = new GraphQLClient(
new URL(appConfig.graphqlEndpoint, window.location.toString()).toString(),
);

View File

@@ -4,22 +4,29 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { TooltipProvider } from "@vector-im/compound-web";
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { Provider as UrqlProvider } from "urql";
import ErrorBoundary from "./components/ErrorBoundary";
import GenericError from "./components/GenericError";
import LoadingScreen from "./components/LoadingScreen";
import config from "./config";
import { client } from "./graphql";
import i18n from "./i18n";
import { routeTree } from "./routeTree.gen";
import "./shared.css";
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
throwOnError: true,
},
},
});
// Create a new router instance
const router = createRouter({
routeTree,
@@ -27,7 +34,7 @@ const router = createRouter({
defaultErrorComponent: GenericError,
defaultPreload: "intent",
defaultPendingMinMs: 0,
context: { client },
context: { queryClient },
});
// Register the router instance for type safety
@@ -39,16 +46,16 @@ declare module "@tanstack/react-router" {
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<UrqlProvider value={client}>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<TooltipProvider>
<Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}>
<RouterProvider router={router} context={{ client }} />
<RouterProvider router={router} context={{ queryClient }} />
</I18nextProvider>
</Suspense>
</TooltipProvider>
</ErrorBoundary>
</UrqlProvider>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -19,20 +19,20 @@ export const FIRST_PAGE = Symbol("FIRST_PAGE");
export const LAST_PAGE = Symbol("LAST_PAGE");
export const anyPaginationSchema = z.object({
first: z.number().optional(),
after: z.string().optional(),
last: z.number().optional(),
before: z.string().optional(),
first: z.number().nullish(),
after: z.string().nullish(),
last: z.number().nullish(),
before: z.string().nullish(),
});
export const forwardPaginationSchema = z.object({
first: z.number(),
after: z.string().optional(),
after: z.string().nullish(),
});
const backwardPaginationSchema = z.object({
last: z.number(),
before: z.string().optional(),
before: z.string().nullish(),
});
const paginationSchema = z.union([

View File

@@ -4,28 +4,38 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
Outlet,
ScrollRestoration,
createRootRouteWithContext,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import type { Client } from "urql";
import Layout from "../components/Layout";
import Layout, { query } from "../components/Layout";
import NotFound from "../components/NotFound";
export const Route = createRootRouteWithContext<{
client: Client;
queryClient: QueryClient;
}>()({
component: () => (
<>
<ScrollRestoration />
<Outlet />
{import.meta.env.DEV && <TanStackRouterDevtools />}
{import.meta.env.DEV && (
<>
<TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="top-right" />
</>
)}
</>
),
loader({ context }) {
context.queryClient.ensureQueryData(query);
},
notFoundComponent: () => (
<Layout>
<NotFound />

View File

@@ -4,6 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useSuspenseQuery } from "@tanstack/react-query";
import {
createLazyFileRoute,
notFound,
@@ -12,7 +13,6 @@ import {
import { Alert, Heading, Separator, Text } from "@vector-im/compound-web";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview";
import BlockList from "../components/BlockList";
@@ -23,7 +23,7 @@ import UserEmail from "../components/UserEmail";
import AddEmailForm from "../components/UserProfile/AddEmailForm";
import UserEmailList from "../components/UserProfile/UserEmailList";
import { QUERY } from "./_account.index";
import { query } from "./_account.index";
export const Route = createLazyFileRoute("/_account/")({
component: Index,
@@ -32,12 +32,10 @@ export const Route = createLazyFileRoute("/_account/")({
function Index(): React.ReactElement {
const navigate = useNavigate();
const { t } = useTranslation();
const [result] = useQuery({ query: QUERY });
if (result.error) throw result.error;
const user = result.data?.viewer;
if (user?.__typename !== "User") throw notFound();
const siteConfig = result.data?.siteConfig;
if (!siteConfig) throw Error(); // This should never happen
const {
data: { viewer, siteConfig },
} = useSuspenseQuery(query);
if (viewer?.__typename !== "User") throw notFound();
// When adding an email, we want to go to the email verification form
const onAdd = async (id: string): Promise<void> => {
@@ -49,9 +47,9 @@ function Index(): React.ReactElement {
<BlockList>
{/* This wrapper is only needed for the anchor link */}
<div className="flex flex-col gap-4" id="emails">
{user.primaryEmail ? (
{viewer.primaryEmail ? (
<UserEmail
email={user.primaryEmail}
email={viewer.primaryEmail}
isPrimary
siteConfig={siteConfig}
/>
@@ -63,11 +61,11 @@ function Index(): React.ReactElement {
)}
<Suspense fallback={<LoadingSpinner mini className="self-center" />}>
<UserEmailList siteConfig={siteConfig} user={user} />
<UserEmailList siteConfig={siteConfig} user={viewer} />
</Suspense>
{siteConfig.emailChangeAllowed && (
<AddEmailForm userId={user.id} onAdd={onAdd} />
<AddEmailForm userId={viewer.id} onAdd={onAdd} />
)}
</div>

View File

@@ -4,13 +4,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { zodSearchValidator } from "@tanstack/router-zod-adapter";
import * as z from "zod";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query UserProfileQuery {
viewer {
__typename
@@ -37,6 +39,11 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = queryOptions({
queryKey: ["userProfile"],
queryFn: ({ signal }) => graphqlClient.request({ document: QUERY, signal }),
});
const actionSchema = z
.discriminatedUnion("action", [
z.object({
@@ -101,13 +108,5 @@ export const Route = createFileRoute("/_account/")({
}
},
async loader({ context, abortController: { signal } }) {
const result = await context.client.query(
QUERY,
{},
{ fetchOptions: { signal } },
);
if (result.error) throw result.error;
if (result.data?.viewer.__typename !== "User") throw notFound();
},
loader: ({ context }) => context.queryClient.ensureQueryData(query),
});

View File

@@ -7,7 +7,6 @@
import { Outlet, createLazyFileRoute, notFound } from "@tanstack/react-router";
import { Heading } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { useEndBrowserSession } from "../components/BrowserSession";
import Layout from "../components/Layout";
@@ -17,7 +16,8 @@ import EndSessionButton from "../components/Session/EndSessionButton";
import UnverifiedEmailAlert from "../components/UnverifiedEmailAlert";
import UserGreeting from "../components/UserGreeting";
import { QUERY } from "./_account";
import { useSuspenseQuery } from "@tanstack/react-query";
import { query } from "./_account";
export const Route = createLazyFileRoute("/_account")({
component: Account,
@@ -25,14 +25,10 @@ export const Route = createLazyFileRoute("/_account")({
function Account(): React.ReactElement {
const { t } = useTranslation();
const [result] = useQuery({
query: QUERY,
});
if (result.error) throw result.error;
const session = result.data?.viewerSession;
const result = useSuspenseQuery(query);
const session = result.data.viewerSession;
if (session?.__typename !== "BrowserSession") throw notFound();
const siteConfig = result.data?.siteConfig;
if (!siteConfig) throw Error(); // This should never happen
const siteConfig = result.data.siteConfig;
const onSessionEnd = useEndBrowserSession(session.id, true);
return (

View File

@@ -7,14 +7,14 @@
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
import { Alert } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { Link } from "../components/Link";
import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
import CompatSessionDetail from "../components/SessionDetail/CompatSessionDetail";
import OAuth2SessionDetail from "../components/SessionDetail/OAuth2SessionDetail";
import { QUERY } from "./_account.sessions.$id";
import { useSuspenseQuery } from "@tanstack/react-query";
import { query } from "./_account.sessions.$id";
export const Route = createLazyFileRoute("/_account/sessions/$id")({
notFoundComponent: NotFound,
@@ -38,11 +38,10 @@ function NotFound(): React.ReactElement {
function SessionDetail(): React.ReactElement {
const { id } = Route.useParams();
const [result] = useQuery({ query: QUERY, variables: { id } });
if (result.error) throw result.error;
const node = result.data?.node;
const {
data: { node, viewerSession },
} = useSuspenseQuery(query(id));
if (!node) throw notFound();
const currentSessionId = result.data?.viewerSession?.id;
switch (node.__typename) {
case "CompatSession":
@@ -53,7 +52,7 @@ function SessionDetail(): React.ReactElement {
return (
<BrowserSessionDetail
session={node}
isCurrent={node.id === currentSessionId}
isCurrent={node.id === viewerSession.id}
/>
);
default:

View File

@@ -4,11 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query SessionDetailQuery($id: ID!) {
viewerSession {
... on Node {
@@ -26,14 +28,14 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = (id: string) =>
queryOptions({
queryKey: ["sessionDetail", id],
queryFn: ({ signal }) =>
graphqlClient.request({ document: QUERY, signal, variables: { id } }),
});
export const Route = createFileRoute("/_account/sessions/$id")({
async loader({ context, params, abortController: { signal } }) {
const result = await context.client.query(
QUERY,
{ id: params.id },
{ fetchOptions: { signal } },
);
if (result.error) throw result.error;
if (!result.data?.node) throw notFound();
},
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(query(params.id)),
});

View File

@@ -7,7 +7,6 @@
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
import { H5 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import BlockList from "../components/BlockList";
import BrowserSession from "../components/BrowserSession";
@@ -15,9 +14,9 @@ import { ButtonLink } from "../components/ButtonLink";
import EmptyState from "../components/EmptyState";
import Filter from "../components/Filter";
import { usePages } from "../pagination";
import { getNinetyDaysAgo } from "../utils/dates";
import { QUERY } from "./_account.sessions.browsers";
import { useSuspenseQuery } from "@tanstack/react-query";
import { query } from "./_account.sessions.browsers";
const PAGE_SIZE = 6;
@@ -29,27 +28,19 @@ function BrowserSessions(): React.ReactElement {
const { t } = useTranslation();
const { inactive, pagination } = Route.useLoaderDeps();
const variables = {
lastActive: inactive ? { before: getNinetyDaysAgo() } : undefined,
...pagination,
};
const [list] = useQuery({ query: QUERY, variables });
if (list.error) throw list.error;
const currentSession =
list.data?.viewerSession.__typename === "BrowserSession"
? list.data.viewerSession
: null;
if (currentSession === null) throw notFound();
const {
data: { viewerSession },
} = useSuspenseQuery(query(pagination, inactive));
if (viewerSession.__typename !== "BrowserSession") throw notFound();
const [backwardPage, forwardPage] = usePages(
pagination,
currentSession.user.browserSessions.pageInfo,
viewerSession.user.browserSessions.pageInfo,
PAGE_SIZE,
);
// We reverse the list as we are paginating backwards
const edges = [...currentSession.user.browserSessions.edges].reverse();
const edges = [...viewerSession.user.browserSessions.edges].reverse();
return (
<BlockList>
<H5>{t("frontend.browser_sessions_overview.heading")}</H5>
@@ -68,11 +59,11 @@ function BrowserSessions(): React.ReactElement {
<BrowserSession
key={n.cursor}
session={n.node}
isCurrent={currentSession.id === n.node.id}
isCurrent={viewerSession.id === n.node.id}
/>
))}
{currentSession.user.browserSessions.totalCount === 0 && (
{viewerSession.user.browserSessions.totalCount === 0 && (
<EmptyState>
{inactive
? t(

View File

@@ -4,17 +4,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { zodSearchValidator } from "@tanstack/router-zod-adapter";
import * as z from "zod";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { anyPaginationSchema, normalizePagination } from "../pagination";
import { graphqlClient } from "../graphql";
import {
type AnyPagination,
anyPaginationSchema,
normalizePagination,
} from "../pagination";
import { getNinetyDaysAgo } from "../utils/dates";
const PAGE_SIZE = 6;
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query BrowserSessionList(
$first: Int
$after: String
@@ -61,6 +67,20 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = (pagination: AnyPagination, inactive: true | undefined) =>
queryOptions({
queryKey: ["browserSessionList", inactive, pagination],
queryFn: ({ signal }) =>
graphqlClient.request({
document: QUERY,
variables: {
lastActive: inactive ? { before: getNinetyDaysAgo() } : undefined,
...pagination,
},
signal,
}),
});
const searchSchema = z
.object({
inactive: z.literal(true).optional(),
@@ -75,21 +95,6 @@ export const Route = createFileRoute("/_account/sessions/browsers")({
pagination: normalizePagination(pagination, PAGE_SIZE, "backward"),
}),
async loader({
context,
deps: { inactive, pagination },
abortController: { signal },
}) {
const variables = {
lastActive: inactive ? { before: getNinetyDaysAgo() } : undefined,
...pagination,
};
const result = await context.client.query(QUERY, variables, {
fetchOptions: { signal },
});
if (result.error) throw result.error;
if (result.data?.viewerSession?.__typename !== "BrowserSession")
throw notFound();
},
loader: ({ context, deps: { inactive, pagination } }) =>
context.queryClient.ensureQueryData(query(pagination, inactive)),
});

View File

@@ -7,7 +7,6 @@
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
import { H3, Separator } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import BlockList from "../components/BlockList";
import { ButtonLink } from "../components/ButtonLink";
@@ -17,9 +16,9 @@ import Filter from "../components/Filter";
import OAuth2Session from "../components/OAuth2Session";
import BrowserSessionsOverview from "../components/UserSessionsOverview/BrowserSessionsOverview";
import { usePages } from "../pagination";
import { getNinetyDaysAgo } from "../utils/dates";
import { LIST_QUERY, QUERY } from "./_account.sessions.index";
import { useSuspenseQuery } from "@tanstack/react-query";
import { listQuery, query } from "./_account.sessions.index";
const PAGE_SIZE = 6;
@@ -35,24 +34,14 @@ export const Route = createLazyFileRoute("/_account/sessions/")({
function Sessions(): React.ReactElement {
const { t } = useTranslation();
const { inactive, pagination } = Route.useLoaderDeps();
const [overview] = useQuery({ query: QUERY });
if (overview.error) throw overview.error;
const user =
overview.data?.viewer.__typename === "User" ? overview.data.viewer : null;
if (user === null) throw notFound();
const {
data: { viewer },
} = useSuspenseQuery(query);
if (viewer.__typename !== "User") throw notFound();
const variables = {
lastActive: inactive ? { before: getNinetyDaysAgo() } : undefined,
...pagination,
};
const [list] = useQuery({ query: LIST_QUERY, variables });
if (list.error) throw list.error;
const appSessions =
list.data?.viewer.__typename === "User"
? list.data.viewer.appSessions
: null;
if (appSessions === null) throw notFound();
const { data } = useSuspenseQuery(listQuery(pagination, inactive));
if (data.viewer.__typename !== "User") throw notFound();
const appSessions = data.viewer.appSessions;
const [backwardPage, forwardPage] = usePages(
pagination,
@@ -66,7 +55,7 @@ function Sessions(): React.ReactElement {
return (
<BlockList>
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
<BrowserSessionsOverview user={user} />
<BrowserSessionsOverview user={viewer} />
<Separator />
<div className="flex gap-2 justify-start items-center">
<Filter

View File

@@ -4,17 +4,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { zodSearchValidator } from "@tanstack/router-zod-adapter";
import * as z from "zod";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { anyPaginationSchema, normalizePagination } from "../pagination";
import { graphqlClient } from "../graphql";
import {
type AnyPagination,
anyPaginationSchema,
normalizePagination,
} from "../pagination";
import { getNinetyDaysAgo } from "../utils/dates";
const PAGE_SIZE = 6;
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query SessionsOverviewQuery {
viewer {
__typename
@@ -27,7 +33,12 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const LIST_QUERY = graphql(/* GraphQL */ `
export const query = queryOptions({
queryKey: ["sessionsOverview"],
queryFn: ({ signal }) => graphqlClient.request({ document: QUERY, signal }),
});
const LIST_QUERY = graphql(/* GraphQL */ `
query AppSessionsListQuery(
$before: String
$after: String
@@ -70,6 +81,23 @@ export const LIST_QUERY = graphql(/* GraphQL */ `
}
`);
export const listQuery = (
pagination: AnyPagination,
inactive: true | undefined,
) =>
queryOptions({
queryKey: ["appSessionList", inactive, pagination],
queryFn: ({ signal }) =>
graphqlClient.request({
document: LIST_QUERY,
variables: {
lastActive: inactive ? { before: getNinetyDaysAgo() } : undefined,
...pagination,
},
signal,
}),
});
const searchSchema = z
.object({
inactive: z.literal(true).optional(),
@@ -84,26 +112,9 @@ export const Route = createFileRoute("/_account/sessions/")({
pagination: normalizePagination(pagination, PAGE_SIZE, "backward"),
}),
async loader({
context,
deps: { inactive, pagination },
abortController: { signal },
}) {
const variables = {
lastActive: inactive ? { before: getNinetyDaysAgo() } : undefined,
...pagination,
};
const [overview, list] = await Promise.all([
context.client.query(QUERY, {}, { fetchOptions: { signal } }),
context.client.query(LIST_QUERY, variables, {
fetchOptions: { signal },
}),
]);
if (overview.error) throw overview.error;
if (list.error) throw list.error;
if (overview.data?.viewer?.__typename !== "User") throw notFound();
if (list.data?.viewer?.__typename !== "User") throw notFound();
},
loader: ({ context, deps: { inactive, pagination } }) =>
Promise.all([
context.queryClient.ensureQueryData(query),
context.queryClient.ensureQueryData(listQuery(pagination, inactive)),
]),
});

View File

@@ -4,11 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { queryOptions } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query CurrentUserGreeting {
viewerSession {
__typename
@@ -31,15 +33,11 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const Route = createFileRoute("/_account")({
loader: async ({ context, abortController: { signal } }) => {
const result = await context.client.query(
QUERY,
{},
{ fetchOptions: { signal } },
);
if (result.error) throw result.error;
if (result.data?.viewerSession.__typename !== "BrowserSession")
throw notFound();
},
export const query = queryOptions({
queryKey: ["currentUserGreeting"],
queryFn: ({ signal }) => graphqlClient.request({ document: QUERY, signal }),
});
export const Route = createFileRoute("/_account")({
loader: ({ context }) => context.queryClient.ensureQueryData(query),
});

View File

@@ -4,13 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createLazyFileRoute } from "@tanstack/react-router";
import { useQuery } from "urql";
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail";
import Layout from "../components/Layout";
import { QUERY } from "./clients.$id";
import { useSuspenseQuery } from "@tanstack/react-query";
import { query } from "./clients.$id";
export const Route = createLazyFileRoute("/clients/$id")({
component: ClientDetail,
@@ -18,17 +18,14 @@ export const Route = createLazyFileRoute("/clients/$id")({
function ClientDetail(): React.ReactElement {
const { id } = Route.useParams();
const [result] = useQuery({
query: QUERY,
variables: { id },
});
if (result.error) throw result.error;
const client = result.data?.oauth2Client;
if (!client) throw new Error(); // Should have been caught by the loader
const {
data: { oauth2Client },
} = useSuspenseQuery(query(id));
if (!oauth2Client) throw notFound();
return (
<Layout>
<OAuth2ClientDetail client={client} />
<OAuth2ClientDetail client={oauth2Client} />
</Layout>
);
}

View File

@@ -4,11 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query OAuth2ClientQuery($id: ID!) {
oauth2Client(id: $id) {
...OAuth2Client_detail
@@ -16,14 +18,14 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = (id: string) =>
queryOptions({
queryKey: ["oauth2Client", id],
queryFn: ({ signal }) =>
graphqlClient.request({ document: QUERY, variables: { id }, signal }),
});
export const Route = createFileRoute("/clients/$id")({
loader: async ({ context, params, abortController: { signal } }) => {
const result = await context.client.query(
QUERY,
{ id: params.id },
{ fetchOptions: { signal } },
);
if (result.error) throw result.error;
if (!result.data?.oauth2Client) throw notFound();
},
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(query(params.id)),
});

View File

@@ -8,9 +8,11 @@ import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { Alert } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { queryOptions } from "@tanstack/react-query";
import Layout from "../components/Layout";
import { Link } from "../components/Link";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
query CurrentViewerQuery {
@@ -23,6 +25,15 @@ const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
}
`);
const currentViewerQuery = queryOptions({
queryKey: ["currentViewer"],
queryFn: ({ signal }) =>
graphqlClient.request({
document: CURRENT_VIEWER_QUERY,
signal,
}),
});
const QUERY = graphql(/* GraphQL */ `
query DeviceRedirectQuery($deviceId: String!, $userId: ID!) {
session(deviceId: $deviceId, userId: $userId) {
@@ -34,33 +45,34 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
export const Route = createFileRoute("/devices/$")({
async loader({ context, params, abortController: { signal } }) {
const viewer = await context.client.query(
CURRENT_VIEWER_QUERY,
{},
{
fetchOptions: { signal },
},
);
if (viewer.error) throw viewer.error;
if (viewer.data?.viewer.__typename !== "User") throw notFound();
const query = (deviceId: string, userId: string) =>
queryOptions({
queryKey: ["deviceRedirect", deviceId, userId],
queryFn: ({ signal }) =>
graphqlClient.request({
document: QUERY,
variables: { deviceId, userId },
signal,
}),
});
const result = await context.client.query(
QUERY,
{
deviceId: params._splat || "",
userId: viewer.data.viewer.id,
},
{ fetchOptions: { signal } },
export const Route = createFileRoute("/devices/$")({
async loader({ context, params }) {
const data = await context.queryClient.fetchQuery(currentViewerQuery);
if (data.viewer.__typename !== "User")
throw notFound({
global: true,
});
const result = await context.queryClient.fetchQuery(
query(params._splat || "", data.viewer.id),
);
if (result.error) throw result.error;
const session = result.data?.session;
if (!session) throw notFound();
if (!result.session) throw notFound();
throw redirect({
to: "/sessions/$id",
params: { id: session.id },
params: { id: result.session.id },
replace: true,
});
},

View File

@@ -4,13 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useSuspenseQuery } from "@tanstack/react-query";
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
import { useQuery } from "urql";
import Layout from "../components/Layout";
import VerifyEmailComponent from "../components/VerifyEmail";
import { QUERY } from "./emails.$id.verify";
import { query } from "./emails.$id.verify";
export const Route = createLazyFileRoute("/emails/$id/verify")({
component: EmailVerify,
@@ -18,15 +18,14 @@ export const Route = createLazyFileRoute("/emails/$id/verify")({
function EmailVerify(): React.ReactElement {
const { id } = Route.useParams();
const [result] = useQuery({ query: QUERY, variables: { id } });
if (result.error) throw result.error;
const email = result.data?.userEmail;
if (email == null) throw notFound();
const {
data: { userEmail },
} = useSuspenseQuery(query(id));
if (!userEmail) throw notFound();
return (
<Layout>
<VerifyEmailComponent email={email} />
<VerifyEmailComponent email={userEmail} />
</Layout>
);
}

View File

@@ -4,11 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { queryOptions } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query VerifyEmailQuery($id: ID!) {
userEmail(id: $id) {
...UserEmail_verifyEmail
@@ -16,16 +18,14 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = (id: string) =>
queryOptions({
queryKey: ["verifyEmail", id],
queryFn: ({ signal }) =>
graphqlClient.request({ document: QUERY, signal, variables: { id } }),
});
export const Route = createFileRoute("/emails/$id/verify")({
async loader({ context, params, abortController: { signal } }) {
const result = await context.client.query(
QUERY,
{
id: params.id,
},
{ fetchOptions: { signal } },
);
if (result.error) throw result.error;
if (!result.data?.userEmail) throw notFound();
},
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(query(params.id)),
});

View File

@@ -4,6 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import {
createLazyFileRoute,
notFound,
@@ -13,7 +14,6 @@ import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lo
import { Alert, Form, Separator } from "@vector-im/compound-web";
import { type FormEvent, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "urql";
import BlockList from "../components/BlockList";
import { ButtonLink } from "../components/ButtonLink";
@@ -22,10 +22,10 @@ import LoadingSpinner from "../components/LoadingSpinner";
import PageHeading from "../components/PageHeading";
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
import { graphql } from "../gql";
import { SetPasswordStatus } from "../gql/graphql";
import { translateSetPasswordError } from "../i18n/password_changes";
import { QUERY } from "./password.change.index";
import { graphqlClient } from "../graphql";
import { query } from "./password.change.index";
const CHANGE_PASSWORD_MUTATION = graphql(/* GraphQL */ `
mutation ChangePassword(
@@ -51,43 +51,52 @@ export const Route = createLazyFileRoute("/password/change/")({
function ChangePassword(): React.ReactNode {
const { t } = useTranslation();
const [queryResult] = useQuery({ query: QUERY });
const {
data: { viewer, siteConfig },
} = useSuspenseQuery(query);
const router = useRouter();
if (queryResult.error) throw queryResult.error;
if (queryResult.data?.viewer.__typename !== "User") throw notFound();
const userId = queryResult.data.viewer.id;
const siteConfig = queryResult.data?.siteConfig;
if (!siteConfig) throw Error(); // This should never happen
if (viewer.__typename !== "User") throw notFound();
const userId = viewer.id;
const currentPasswordRef = useRef<HTMLInputElement>(null);
const [result, changePassword] = useMutation(CHANGE_PASSWORD_MUTATION);
const mutation = useMutation({
async mutationFn(formData: FormData) {
const oldPassword = formData.get("current_password") as string;
const newPassword = formData.get("new_password") as string;
const newPasswordAgain = formData.get("new_password_again") as string;
if (newPassword !== newPasswordAgain) {
throw new Error(
"passwords mismatch; this should be checked by the form",
);
}
const response = await graphqlClient.request(CHANGE_PASSWORD_MUTATION, {
userId,
oldPassword,
newPassword,
});
if (response.setPassword.status === "ALLOWED") {
router.navigate({ to: "/password/change/success" });
}
return response.setPassword;
},
});
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const oldPassword = formData.get("current_password") as string;
const newPassword = formData.get("new_password") as string;
const newPasswordAgain = formData.get("new_password_again") as string;
if (newPassword !== newPasswordAgain) {
throw new Error("passwords mismatch; this should be checked by the form");
}
const response = await changePassword({ userId, oldPassword, newPassword });
if (response.data?.setPassword.status === "ALLOWED") {
router.navigate({ to: "/password/change/success" });
}
mutation.mutate(formData);
};
const unhandleableError = result.error !== undefined;
const unhandleableError = mutation.error !== null;
const errorMsg: string | undefined = translateSetPasswordError(
t,
result.data?.setPassword.status,
mutation.data?.status,
);
return (
@@ -125,7 +134,7 @@ function ChangePassword(): React.ReactNode {
<Form.Field
name="current_password"
serverInvalid={result.data?.setPassword.status === "WRONG_PASSWORD"}
serverInvalid={mutation.data?.status === "WRONG_PASSWORD"}
>
<Form.Label>
{t("frontend.password_change.current_password_label")}
@@ -141,14 +150,13 @@ function ChangePassword(): React.ReactNode {
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{result.data &&
result.data.setPassword.status === "WRONG_PASSWORD" && (
<Form.ErrorMessage>
{t(
"frontend.password_change.failure.description.wrong_password",
)}
</Form.ErrorMessage>
)}
{mutation.data && mutation.data.status === "WRONG_PASSWORD" && (
<Form.ErrorMessage>
{t(
"frontend.password_change.failure.description.wrong_password",
)}
</Form.ErrorMessage>
)}
</Form.Field>
<Separator />
@@ -156,14 +164,14 @@ function ChangePassword(): React.ReactNode {
<PasswordCreationDoubleInput
siteConfig={siteConfig}
forceShowNewPasswordInvalid={
(result.data &&
result.data.setPassword.status === "INVALID_NEW_PASSWORD") ||
(mutation.data &&
mutation.data.status === "INVALID_NEW_PASSWORD") ||
false
}
/>
<Form.Submit kind="primary" disabled={result.fetching}>
{!!result.fetching && <LoadingSpinner inline />}
<Form.Submit kind="primary" disabled={mutation.isPending}>
{!!mutation.isPending && <LoadingSpinner inline />}
{t("action.save")}
</Form.Submit>

View File

@@ -4,11 +4,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query PasswordChangeQuery {
viewer {
__typename
@@ -23,14 +25,11 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const Route = createFileRoute("/password/change/")({
async loader({ context, abortController: { signal } }) {
const queryResult = await context.client.query(
QUERY,
{},
{ fetchOptions: { signal } },
);
if (queryResult.error) throw queryResult.error;
if (queryResult.data?.viewer.__typename !== "User") throw notFound();
},
export const query = queryOptions({
queryKey: ["passwordChange"],
queryFn: ({ signal }) => graphqlClient.request({ document: QUERY, signal }),
});
export const Route = createFileRoute("/password/change/")({
loader: ({ context }) => context.queryClient.ensureQueryData(query),
});

View File

@@ -4,6 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import {
createLazyFileRoute,
useRouter,
@@ -13,7 +14,6 @@ import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lo
import { Alert, Form } from "@vector-im/compound-web";
import type { FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "urql";
import BlockList from "../components/BlockList";
import Layout from "../components/Layout";
@@ -21,10 +21,10 @@ import LoadingSpinner from "../components/LoadingSpinner";
import PageHeading from "../components/PageHeading";
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
import { graphql } from "../gql";
import { SetPasswordStatus } from "../gql/graphql";
import { translateSetPasswordError } from "../i18n/password_changes";
import { QUERY } from "./password.recovery.index";
import { graphqlClient } from "../graphql";
import { query } from "./password.recovery.index";
const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ `
mutation RecoverPassword($ticket: String!, $newPassword: String!) {
@@ -45,43 +45,55 @@ function RecoverPassword(): React.ReactNode {
const { ticket } = useSearch({
from: "/password/recovery/",
});
const [queryResult] = useQuery({ query: QUERY });
const {
data: { siteConfig },
} = useSuspenseQuery(query);
const router = useRouter();
if (queryResult.error) throw queryResult.error;
const siteConfig = queryResult.data?.siteConfig;
if (!siteConfig) throw Error(); // This should never happen
const [result, changePassword] = useMutation(RECOVER_PASSWORD_MUTATION);
const mutation = useMutation({
mutationFn: async ({
ticket,
form,
}: { ticket: string; form: FormData }) => {
const newPassword = form.get("new_password") as string;
const newPasswordAgain = form.get("new_password_again") as string;
if (newPassword !== newPasswordAgain) {
throw new Error(
"passwords mismatch; this should be checked by the form",
);
}
const response = await graphqlClient.request(RECOVER_PASSWORD_MUTATION, {
ticket,
newPassword,
});
if (response.setPasswordByRecovery.status === "ALLOWED") {
// Redirect to the application root using a full page load
// The MAS backend will then redirect to the login page
// Unfortunately this won't work in dev mode (`npm run dev`)
// as the backend isn't involved there.
const location = router.buildLocation({ to: "/" });
window.location.href = location.href;
}
return response.setPasswordByRecovery;
},
});
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const newPassword = formData.get("new_password") as string;
const newPasswordAgain = formData.get("new_password_again") as string;
if (newPassword !== newPasswordAgain) {
throw new Error("passwords mismatch; this should be checked by the form");
}
const response = await changePassword({ ticket, newPassword });
if (response.data?.setPasswordByRecovery.status === "ALLOWED") {
// Redirect to the application root using a full page load
// The MAS backend will then redirect to the login page
// Unfortunately this won't work in dev mode (`npm run dev`)
// as the backend isn't involved there.
const location = router.buildLocation({ to: "/" });
window.location.href = location.href;
}
const form = new FormData(event.currentTarget);
mutation.mutate({ ticket, form });
};
const unhandleableError = result.error !== undefined;
const unhandleableError = mutation.error !== undefined;
const errorMsg: string | undefined = translateSetPasswordError(
t,
result.data?.setPasswordByRecovery.status,
mutation.data?.status,
);
return (
@@ -120,15 +132,12 @@ function RecoverPassword(): React.ReactNode {
<PasswordCreationDoubleInput
siteConfig={siteConfig}
forceShowNewPasswordInvalid={
(result.data &&
result.data.setPasswordByRecovery.status ===
"INVALID_NEW_PASSWORD") ||
false
mutation.data?.status === "INVALID_NEW_PASSWORD" || false
}
/>
<Form.Submit kind="primary" disabled={result.fetching}>
{!!result.fetching && <LoadingSpinner inline />}
<Form.Submit kind="primary" disabled={mutation.isPending}>
{!!mutation.isPending && <LoadingSpinner inline />}
{t("action.save_and_continue")}
</Form.Submit>
</Form.Root>

View File

@@ -8,9 +8,11 @@ import { createFileRoute } from "@tanstack/react-router";
import { zodSearchValidator } from "@tanstack/router-zod-adapter";
import * as z from "zod";
import { queryOptions } from "@tanstack/react-query";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
export const QUERY = graphql(/* GraphQL */ `
const QUERY = graphql(/* GraphQL */ `
query PasswordRecoveryQuery {
siteConfig {
id
@@ -19,6 +21,11 @@ export const QUERY = graphql(/* GraphQL */ `
}
`);
export const query = queryOptions({
queryKey: ["passwordRecovery"],
queryFn: ({ signal }) => graphqlClient.request({ document: QUERY, signal }),
});
const schema = z.object({
ticket: z.string(),
});
@@ -26,12 +33,5 @@ const schema = z.object({
export const Route = createFileRoute("/password/recovery/")({
validateSearch: zodSearchValidator(schema),
async loader({ context, abortController: { signal } }) {
const queryResult = await context.client.query(
QUERY,
{},
{ fetchOptions: { signal } },
);
if (queryResult.error) throw queryResult.error;
},
loader: ({ context }) => context.queryClient.ensureQueryData(query),
});

View File

@@ -4,13 +4,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import {
queryOptions,
useMutation,
useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute, notFound } from "@tanstack/react-router";
import IconCheck from "@vector-im/compound-design-tokens/assets/web/icons/check";
import IconError from "@vector-im/compound-design-tokens/assets/web/icons/error";
import IconInfo from "@vector-im/compound-design-tokens/assets/web/icons/info";
import { Button, Text } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "urql";
import { ButtonLink } from "../components/ButtonLink";
import LoadingSpinner from "../components/LoadingSpinner";
import PageHeading from "../components/PageHeading";
@@ -19,6 +23,7 @@ import {
VisualListItem,
} from "../components/VisualList/VisualList";
import { graphql } from "../gql";
import { graphqlClient } from "../graphql";
const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
query CurrentViewerQuery {
@@ -31,16 +36,18 @@ const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
}
`);
const currentViewerQuery = queryOptions({
queryKey: ["currentViewer"],
queryFn: ({ signal }) =>
graphqlClient.request({
document: CURRENT_VIEWER_QUERY,
signal,
}),
});
export const Route = createFileRoute("/reset-cross-signing/")({
async loader({ context, abortController: { signal } }) {
const viewer = await context.client.query(
CURRENT_VIEWER_QUERY,
{},
{ fetchOptions: { signal } },
);
if (viewer.error) throw viewer.error;
if (viewer.data?.viewer.__typename !== "User") throw notFound();
},
loader: ({ context }) =>
context.queryClient.ensureQueryData(currentViewerQuery),
component: ResetCrossSigning,
});
@@ -68,30 +75,36 @@ function ResetCrossSigning(): React.ReactNode {
const { deepLink } = Route.useSearch();
const navigate = Route.useNavigate();
const { t } = useTranslation();
const [viewer] = useQuery({ query: CURRENT_VIEWER_QUERY });
if (viewer.error) throw viewer.error;
if (viewer.data?.viewer.__typename !== "User") throw notFound();
const userId = viewer.data.viewer.id;
const {
data: { viewer },
} = useSuspenseQuery(currentViewerQuery);
if (viewer.__typename !== "User") throw notFound();
const userId = viewer.id;
const [result, allowReset] = useMutation(ALLOW_CROSS_SIGING_RESET_MUTATION);
if (result.error) throw result.error;
const success = !!result.data;
const mutation = useMutation({
mutationFn: async (userId: string) =>
graphqlClient.request(ALLOW_CROSS_SIGING_RESET_MUTATION, {
userId,
}),
onSuccess: () => {
setTimeout(() => {
// Synapse may fling the user here via UIA fallback,
// this is part of the API to signal completion to the calling client
// https://spec.matrix.org/v1.11/client-server-api/#fallback
if (window.onAuthDone) {
window.onAuthDone();
} else if (window.opener?.postMessage) {
window.opener.postMessage("authDone", "*");
}
});
navigate({ to: "/reset-cross-signing/success", replace: true });
},
});
const onClick = async (): Promise<void> => {
await allowReset({ userId });
setTimeout(() => {
// Synapse may fling the user here via UIA fallback,
// this is part of the API to signal completion to the calling client
// https://spec.matrix.org/v1.11/client-server-api/#fallback
if (window.onAuthDone) {
window.onAuthDone();
} else if (window.opener?.postMessage) {
window.opener.postMessage("authDone", "*");
}
});
navigate({ to: "/reset-cross-signing/success", replace: true });
mutation.mutate(userId);
};
return (
@@ -129,10 +142,10 @@ function ResetCrossSigning(): React.ReactNode {
<Button
kind="primary"
destructive
disabled={result.fetching}
disabled={mutation.isPending}
onClick={onClick}
>
{!!result.fetching && <LoadingSpinner inline />}
{!!mutation.isPending && <LoadingSpinner inline />}
{t("frontend.reset_cross_signing.finish_reset")}
</Button>