Frontend cleanups

Mainly:

 - better handling of GraphQL errors
 - better logout state
 - dependencies update
 - a way to end browser sessions in the GraphQL API
This commit is contained in:
Quentin Gliech
2023-06-20 15:05:48 +02:00
parent ebb87f0a5e
commit f67cc0d6d0
24 changed files with 1072 additions and 230 deletions
+54 -13
View File
@@ -12,13 +12,52 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { atom } from "jotai";
import { AnyVariables, CombinedError, OperationContext } from "@urql/core";
import { atom, WritableAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { atomWithQuery, clientAtom } from "jotai-urql";
import { AtomWithQuery, atomWithQuery, clientAtom } from "jotai-urql";
import type { ReactElement } from "react";
import { graphql } from "./gql";
import { client } from "./graphql";
import { err, ok, Result } from "./result";
export type GqlResult<T> = Result<T, CombinedError>;
export type GqlAtom<T> = WritableAtom<
Promise<GqlResult<T>>,
[context?: Partial<OperationContext>],
void
>;
/**
* Map the result of a query atom to a new value, making it a GqlResult
*
* @param queryAtom: An atom got from atomWithQuery
* @param mapper: A function that takes the data from the query and returns a new value
*/
export const mapQueryAtom = <Data, Variables extends AnyVariables, NewData>(
queryAtom: AtomWithQuery<Data, Variables>,
mapper: (data: Data) => NewData
): GqlAtom<NewData> => {
return atom(
async (get): Promise<GqlResult<NewData>> => {
const result = await get(queryAtom);
if (result.error) {
return err(result.error);
}
if (result.data === undefined) {
throw new Error("Query result is undefined");
}
return ok(mapper(result.data));
},
(_get, set, context) => {
set(queryAtom, context);
}
);
};
export const HydrateAtoms: React.FC<{ children: ReactElement }> = ({
children,
@@ -44,13 +83,15 @@ const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
const currentViewerAtom = atomWithQuery({ query: CURRENT_VIEWER_QUERY });
export const currentUserIdAtom = atom(async (get) => {
const result = await get(currentViewerAtom);
if (result.data?.viewer.__typename === "User") {
return result.data.viewer.id;
export const currentUserIdAtom: GqlAtom<string | null> = mapQueryAtom(
currentViewerAtom,
(data) => {
if (data.viewer.__typename === "User") {
return data.viewer.id;
}
return null;
}
return null;
});
);
const CURRENT_VIEWER_SESSION_QUERY = graphql(/* GraphQL */ `
query CurrentViewerSessionQuery {
@@ -71,11 +112,11 @@ const currentViewerSessionAtom = atomWithQuery({
query: CURRENT_VIEWER_SESSION_QUERY,
});
export const currentBrowserSessionIdAtom = atom(
async (get): Promise<string | null> => {
const result = await get(currentViewerSessionAtom);
if (result.data?.viewerSession.__typename === "BrowserSession") {
return result.data.viewerSession.id;
export const currentBrowserSessionIdAtom: GqlAtom<string | null> = mapQueryAtom(
currentViewerSessionAtom,
(data) => {
if (data.viewerSession.__typename === "BrowserSession") {
return data.viewerSession.id;
}
return null;
}
+58 -8
View File
@@ -13,8 +13,13 @@
// limitations under the License.
import IconWebBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg";
import { Body } from "@vector-im/compound-web";
import { Body, Button } from "@vector-im/compound-web";
import { atom, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react";
import { currentBrowserSessionIdAtom, currentUserIdAtom } from "../atoms";
import { FragmentType, graphql, useFragment } from "../gql";
import Block from "./Block";
@@ -31,6 +36,30 @@ const FRAGMENT = graphql(/* GraphQL */ `
}
`);
const END_SESSION_MUTATION = graphql(/* GraphQL */ `
mutation EndBrowserSession($id: ID!) {
endBrowserSession(input: { browserSessionId: $id }) {
status
browserSession {
id
...BrowserSession_session
}
}
}
`);
const endSessionFamily = atomFamily((id: string) => {
const endSession = atomWithMutation(END_SESSION_MUTATION);
// A proxy atom which pre-sets the id variable in the mutation
const endSessionAtom = atom(
(get) => get(endSession),
(get, set) => set(endSession, { id })
);
return endSessionAtom;
});
type Props = {
session: FragmentType<typeof FRAGMENT>;
isCurrent: boolean;
@@ -38,10 +67,30 @@ type Props = {
const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
const data = useFragment(FRAGMENT, session);
const [pending, startTransition] = useTransition();
const endSession = useSetAtom(endSessionFamily(data.id));
// Pull those atoms to reset them when the current session is ended
const currentUserId = useSetAtom(currentUserIdAtom);
const currentBrowserSessionId = useSetAtom(currentBrowserSessionIdAtom);
// const lastAuthentication = data.lastAuthentication?.createdAt;
const createdAt = data.createdAt;
const onSessionEnd = () => {
startTransition(() => {
endSession().then(() => {
if (isCurrent) {
currentBrowserSessionId({
requestPolicy: "network-only",
});
currentUserId({
requestPolicy: "network-only",
});
}
});
});
};
return (
<Block className="my-4 flex items-center">
<IconWebBrowser className="mr-4 session-icon" />
@@ -59,15 +108,16 @@ const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
Signed in <DateTime datetime={createdAt} />
</Body>
</div>
<Body
as="a"
<Button
kind="destructive"
size="sm"
weight="medium"
href="#"
className="text-critical underline hover:no-underline"
className="mt-2"
onClick={onSessionEnd}
disabled={pending}
>
Sign out
</Body>
</Button>
</Block>
);
};
+36 -25
View File
@@ -17,7 +17,7 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
import { currentBrowserSessionIdAtom } from "../atoms";
import { currentBrowserSessionIdAtom, mapQueryAtom } from "../atoms";
import { graphql } from "../gql";
import { PageInfo } from "../gql/graphql";
import {
@@ -25,9 +25,11 @@ import {
atomWithPagination,
Pagination,
} from "../pagination";
import { isErr, isOk, unwrapErr, unwrapOk } from "../result";
import BlockList from "./BlockList";
import BrowserSession from "./BrowserSession";
import GraphQLError from "./GraphQLError";
import PaginationControls from "./PaginationControls";
import { Title } from "./Typography";
@@ -69,17 +71,23 @@ const QUERY = graphql(/* GraphQL */ `
const currentPaginationAtom = atomForCurrentPagination();
const browserSessionListFamily = atomFamily((userId: string) => {
const browserSessionList = atomWithQuery({
const browserSessionListQuery = atomWithQuery({
query: QUERY,
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
});
const browserSessionList = mapQueryAtom(
browserSessionListQuery,
(data) => data.user?.browserSessions || null
);
return browserSessionList;
});
const pageInfoFamily = atomFamily((userId: string) => {
const pageInfoAtom = atom(async (get): Promise<PageInfo | null> => {
const result = await get(browserSessionListFamily(userId));
return result.data?.user?.browserSessions?.pageInfo ?? null;
return (isOk(result) && unwrapOk(result)?.pageInfo) || null;
});
return pageInfoAtom;
});
@@ -94,40 +102,43 @@ const paginationFamily = atomFamily((userId: string) => {
});
const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
const currentSessionId = useAtomValue(currentBrowserSessionIdAtom);
const currentSessionIdResult = useAtomValue(currentBrowserSessionIdAtom);
const [pending, startTransition] = useTransition();
const result = useAtomValue(browserSessionListFamily(userId));
const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
if (isErr(currentSessionIdResult))
return <GraphQLError error={unwrapErr(currentSessionIdResult)} />;
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
const browserSessions = unwrapOk(result);
if (browserSessions === null) return <>Failed to load browser sessions</>;
const currentSessionId = unwrapOk(currentSessionIdResult);
const paginate = (pagination: Pagination): void => {
startTransition(() => {
setPagination(pagination);
});
};
if (result.data?.user?.browserSessions) {
const data = result.data.user.browserSessions;
return (
<BlockList>
<Title>List of browser sessions:</Title>
<PaginationControls
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
return (
<BlockList>
<Title>List of browser sessions:</Title>
<PaginationControls
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
/>
{browserSessions.edges.map((n) => (
<BrowserSession
key={n.cursor}
session={n.node}
isCurrent={n.node.id === currentSessionId}
/>
{data.edges.map((n) => (
<BrowserSession
key={n.cursor}
session={n.node}
isCurrent={n.node.id === currentSessionId}
/>
))}
</BlockList>
);
}
return <>Failed to load browser sessions</>;
))}
</BlockList>
);
};
export default BrowserSessionList;
+28 -20
View File
@@ -17,6 +17,7 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
import { mapQueryAtom } from "../atoms";
import { graphql } from "../gql";
import { PageInfo } from "../gql/graphql";
import {
@@ -24,9 +25,11 @@ import {
atomWithPagination,
Pagination,
} from "../pagination";
import { isErr, isOk, unwrapErr, unwrapOk } from "../result";
import BlockList from "./BlockList";
import CompatSsoLogin from "./CompatSsoLogin";
import GraphQLError from "./GraphQLError";
import PaginationControls from "./PaginationControls";
import { Title } from "./Typography";
@@ -67,18 +70,23 @@ const QUERY = graphql(/* GraphQL */ `
const currentPaginationAtom = atomForCurrentPagination();
const compatSsoLoginListFamily = atomFamily((userId: string) => {
const compatSsoLoginList = atomWithQuery({
const compatSsoLoginListQuery = atomWithQuery({
query: QUERY,
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
});
const compatSsoLoginList = mapQueryAtom(
compatSsoLoginListQuery,
(data) => data.user?.compatSsoLogins || null
);
return compatSsoLoginList;
});
const pageInfoFamily = atomFamily((userId: string) => {
const pageInfoAtom = atom(async (get): Promise<PageInfo | null> => {
const result = await get(compatSsoLoginListFamily(userId));
return result.data?.user?.compatSsoLogins?.pageInfo ?? null;
return (isOk(result) && unwrapOk(result)?.pageInfo) || null;
});
return pageInfoAtom;
@@ -98,30 +106,30 @@ const CompatSsoLoginList: React.FC<{ userId: string }> = ({ userId }) => {
const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
const compatSsoLoginList = unwrapOk(result);
if (compatSsoLoginList === null)
return <>Failed to load list of compatibility sessions.</>;
const paginate = (pagination: Pagination): void => {
startTransition(() => {
setPagination(pagination);
});
};
if (result.data?.user?.compatSsoLogins) {
const data = result.data.user.compatSsoLogins;
return (
<BlockList>
<Title>List of compatibility sessions:</Title>
<PaginationControls
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
/>
{data.edges.map((n) => (
<CompatSsoLogin login={n.node} key={n.node.id} />
))}
</BlockList>
);
}
return <>Failed to load list of compatibility sessions.</>;
return (
<BlockList>
<Title>List of compatibility sessions:</Title>
<PaginationControls
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
/>
{compatSsoLoginList.edges.map((n) => (
<CompatSsoLogin login={n.node} key={n.node.id} />
))}
</BlockList>
);
};
export default CompatSsoLoginList;
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CombinedError } from "@urql/core";
import { Alert } from "@vector-im/compound-web";
const GraphQLError: React.FC<{ error: CombinedError }> = ({ error }) => (
<Alert type="critical" title={error.message}>
{error.toString()}
</Alert>
);
export default GraphQLError;
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Alert } from "@vector-im/compound-web";
const NotFound: React.FC = () => <Alert type="critical" title="Not found." />;
export default NotFound;
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Alert } from "@vector-im/compound-web";
const NotLoggedIn: React.FC = () => (
<Alert type="critical" title="You're not logged in." />
);
export default NotLoggedIn;
+1 -1
View File
@@ -41,7 +41,7 @@ const FRAGMENT = graphql(/* GraphQL */ `
`);
const END_SESSION_MUTATION = graphql(/* GraphQL */ `
mutation EndSession($id: ID!) {
mutation EndOAuth2Session($id: ID!) {
endOauth2Session(input: { oauth2SessionId: $id }) {
status
oauth2Session {
+28 -20
View File
@@ -17,6 +17,7 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
import { mapQueryAtom } from "../atoms";
import { graphql } from "../gql";
import { PageInfo } from "../gql/graphql";
import {
@@ -24,8 +25,10 @@ import {
atomWithPagination,
Pagination,
} from "../pagination";
import { isErr, isOk, unwrapErr, unwrapOk } from "../result";
import BlockList from "./BlockList";
import GraphQLError from "./GraphQLError";
import OAuth2Session from "./OAuth2Session";
import PaginationControls from "./PaginationControls";
import { Title } from "./Typography";
@@ -68,18 +71,23 @@ const QUERY = graphql(/* GraphQL */ `
const currentPaginationAtom = atomForCurrentPagination();
const oauth2SessionListFamily = atomFamily((userId: string) => {
const oauth2SessionList = atomWithQuery({
const oauth2SessionListQuery = atomWithQuery({
query: QUERY,
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
});
const oauth2SessionList = mapQueryAtom(
oauth2SessionListQuery,
(data) => data.user?.oauth2Sessions || null
);
return oauth2SessionList;
});
const pageInfoFamily = atomFamily((userId: string) => {
const pageInfoAtom = atom(async (get): Promise<PageInfo | null> => {
const result = await get(oauth2SessionListFamily(userId));
return result.data?.user?.oauth2Sessions?.pageInfo ?? null;
return (isOk(result) && unwrapOk(result)?.pageInfo) || null;
});
return pageInfoAtom;
@@ -103,30 +111,30 @@ const OAuth2SessionList: React.FC<Props> = ({ userId }) => {
const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
const oauth2Sessions = unwrapOk(result);
if (oauth2Sessions === null)
return <>Failed to load OAuth 2.0 session list</>;
const paginate = (pagination: Pagination): void => {
startTransition(() => {
setPagination(pagination);
});
};
if (result.data?.user?.oauth2Sessions) {
const data = result.data.user.oauth2Sessions;
return (
<BlockList>
<Title>List of OAuth 2.0 sessions:</Title>
<PaginationControls
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
/>
{data.edges.map((n) => (
<OAuth2Session key={n.cursor} session={n.node} />
))}
</BlockList>
);
} else {
return <>Failed to load OAuth 2.0 session list</>;
}
return (
<BlockList>
<Title>List of OAuth 2.0 sessions:</Title>
<PaginationControls
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
/>
{oauth2Sessions.edges.map((n) => (
<OAuth2Session key={n.cursor} session={n.node} />
))}
</BlockList>
);
};
export default OAuth2SessionList;
+12 -4
View File
@@ -21,6 +21,8 @@ const documents = {
types.AddEmailDocument,
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
types.BrowserSession_SessionFragmentDoc,
"\n 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 query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
types.BrowserSessionListDocument,
"\n fragment CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n ...CompatSsoLogin_session\n createdAt\n deviceId\n finishedAt\n }\n }\n":
@@ -33,8 +35,8 @@ const documents = {
types.CompatSsoLoginListDocument,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n":
types.OAuth2Session_SessionFragmentDoc,
"\n mutation EndSession($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n":
types.EndSessionDocument,
"\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 query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
types.OAuth2SessionListQueryDocument,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n":
@@ -97,6 +99,12 @@ export function graphql(
export function graphql(
source: "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"
): typeof documents["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n"
): typeof documents["\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -137,8 +145,8 @@ export function graphql(
* 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 EndSession($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n"
): typeof documents["\n mutation EndSession($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n"];
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"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
+152 -5
View File
@@ -184,6 +184,28 @@ export type CreationEvent = {
createdAt: Scalars["DateTime"]["output"];
};
/** The input of the `endBrowserSession` mutation. */
export type EndBrowserSessionInput = {
/** The ID of the session to end. */
browserSessionId: Scalars["ID"]["input"];
};
export type EndBrowserSessionPayload = {
__typename?: "EndBrowserSessionPayload";
/** Returns the ended session. */
browserSession?: Maybe<BrowserSession>;
/** The status of the mutation. */
status: EndBrowserSessionStatus;
};
/** The status of the `endBrowserSession` mutation. */
export enum EndBrowserSessionStatus {
/** The session was ended. */
Ended = "ENDED",
/** The session was not found. */
NotFound = "NOT_FOUND",
}
/** The input of the `endCompatSession` mutation. */
export type EndCompatSessionInput = {
/** The ID of the session to end. */
@@ -243,6 +265,7 @@ export type Mutation = {
__typename?: "Mutation";
/** Add an email address to the specified user */
addEmail: AddEmailPayload;
endBrowserSession: EndBrowserSessionPayload;
endCompatSession: EndCompatSessionPayload;
endOauth2Session: EndOAuth2SessionPayload;
/** Remove an email address */
@@ -260,6 +283,11 @@ export type MutationAddEmailArgs = {
input: AddEmailInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationEndBrowserSessionArgs = {
input: EndBrowserSessionInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationEndCompatSessionArgs = {
input: EndCompatSessionInput;
@@ -777,6 +805,25 @@ export type BrowserSession_SessionFragment = {
} | null;
} & { " $fragmentName"?: "BrowserSession_SessionFragment" };
export type EndBrowserSessionMutationVariables = Exact<{
id: Scalars["ID"]["input"];
}>;
export type EndBrowserSessionMutation = {
__typename?: "Mutation";
endBrowserSession: {
__typename?: "EndBrowserSessionPayload";
status: EndBrowserSessionStatus;
browserSession?:
| ({ __typename?: "BrowserSession"; id: string } & {
" $fragmentRefs"?: {
BrowserSession_SessionFragment: BrowserSession_SessionFragment;
};
})
| null;
};
};
export type BrowserSessionListQueryVariables = Exact<{
userId: Scalars["ID"]["input"];
first?: InputMaybe<Scalars["Int"]["input"]>;
@@ -908,11 +955,11 @@ export type OAuth2Session_SessionFragment = {
};
} & { " $fragmentName"?: "OAuth2Session_SessionFragment" };
export type EndSessionMutationVariables = Exact<{
export type EndOAuth2SessionMutationVariables = Exact<{
id: Scalars["ID"]["input"];
}>;
export type EndSessionMutation = {
export type EndOAuth2SessionMutation = {
__typename?: "Mutation";
endOauth2Session: {
__typename?: "EndOAuth2SessionPayload";
@@ -1532,6 +1579,103 @@ export const AddEmailDocument = {
},
],
} as unknown as DocumentNode<AddEmailMutation, AddEmailMutationVariables>;
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: "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 BrowserSessionListDocument = {
kind: "Document",
definitions: [
@@ -2054,13 +2198,13 @@ export const CompatSsoLoginListDocument = {
CompatSsoLoginListQuery,
CompatSsoLoginListQueryVariables
>;
export const EndSessionDocument = {
export const EndOAuth2SessionDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "mutation",
name: { kind: "Name", value: "EndSession" },
name: { kind: "Name", value: "EndOAuth2Session" },
variableDefinitions: [
{
kind: "VariableDefinition",
@@ -2151,7 +2295,10 @@ export const EndSessionDocument = {
},
},
],
} as unknown as DocumentNode<EndSessionMutation, EndSessionMutationVariables>;
} as unknown as DocumentNode<
EndOAuth2SessionMutation,
EndOAuth2SessionMutationVariables
>;
export const OAuth2SessionListQueryDocument = {
kind: "Document",
definitions: [
+50
View File
@@ -522,6 +522,33 @@ export default {
},
],
},
{
kind: "OBJECT",
name: "EndBrowserSessionPayload",
fields: [
{
name: "browserSession",
type: {
kind: "OBJECT",
name: "BrowserSession",
ofType: null,
},
args: [],
},
{
name: "status",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
args: [],
},
],
interfaces: [],
},
{
kind: "OBJECT",
name: "EndCompatSessionPayload",
@@ -637,6 +664,29 @@ export default {
},
],
},
{
name: "endBrowserSession",
type: {
kind: "NON_NULL",
ofType: {
kind: "OBJECT",
name: "EndBrowserSessionPayload",
ofType: null,
},
},
args: [
{
name: "input",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
},
],
},
{
name: "endCompatSession",
type: {
+13 -9
View File
@@ -15,8 +15,11 @@
import { useAtomValue } from "jotai";
import { currentUserIdAtom } from "../atoms";
import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn";
import UserEmailList from "../components/UserEmailList";
import UserGreeting from "../components/UserGreeting";
import { isErr, unwrapErr, unwrapOk } from "../result";
const UserAccount: React.FC<{ id: string }> = ({ id }) => {
return (
@@ -28,16 +31,17 @@ const UserAccount: React.FC<{ id: string }> = ({ id }) => {
};
const CurrentUserAccount: React.FC = () => {
const userId = useAtomValue(currentUserIdAtom);
if (userId !== null) {
return (
<div className="w-96 mx-auto">
<UserAccount id={userId} />
</div>
);
}
const result = useAtomValue(currentUserIdAtom);
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
return <div className="w-96 mx-auto">Not logged in.</div>;
const userId = unwrapOk(result);
if (userId === null) return <NotLoggedIn />;
return (
<div className="w-96 mx-auto">
<UserAccount id={userId} />
</div>
);
};
export default CurrentUserAccount;
+18 -9
View File
@@ -16,7 +16,11 @@ import { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { mapQueryAtom } from "../atoms";
import GraphQLError from "../components/GraphQLError";
import NotFound from "../components/NotFound";
import { graphql } from "../gql";
import { isErr, unwrapErr, unwrapOk } from "../result";
const QUERY = graphql(/* GraphQL */ `
query BrowserSessionQuery($id: ID!) {
@@ -36,26 +40,31 @@ const QUERY = graphql(/* GraphQL */ `
`);
const browserSessionFamily = atomFamily((id: string) => {
const browserSessionAtom = atomWithQuery({
const browserSessionQueryAtom = atomWithQuery({
query: QUERY,
getVariables: () => ({ id }),
});
const browserSessionAtom = mapQueryAtom(
browserSessionQueryAtom,
(data) => data?.browserSession
);
return browserSessionAtom;
});
const BrowserSession: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(browserSessionFamily(id));
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
if (result.data?.browserSession) {
return (
<pre>
<code>{JSON.stringify(result.data.browserSession, null, 2)}</code>
</pre>
);
}
const browserSession = unwrapOk(result);
if (browserSession === null) return <NotFound />;
return <>Failed to load browser session</>;
return (
<pre>
<code>{JSON.stringify(browserSession, null, 2)}</code>
</pre>
);
};
export default BrowserSession;
+18 -16
View File
@@ -12,32 +12,34 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Alert } from "@vector-im/compound-web";
import { useAtomValue } from "jotai";
import { currentUserIdAtom } from "../atoms";
import BrowserSessionList from "../components/BrowserSessionList";
import CompatSsoLoginList from "../components/CompatSsoLoginList";
import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn";
import OAuth2SessionList from "../components/OAuth2SessionList";
import UserGreeting from "../components/UserGreeting";
import { isErr, unwrapErr, unwrapOk } from "../result";
const Home: React.FC = () => {
const currentUserId = useAtomValue(currentUserIdAtom);
const result = useAtomValue(currentUserIdAtom);
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
if (currentUserId) {
return (
<>
<UserGreeting userId={currentUserId} />
<div className="mt-4 grid gap-1">
<OAuth2SessionList userId={currentUserId} />
<CompatSsoLoginList userId={currentUserId} />
<BrowserSessionList userId={currentUserId} />
</div>
</>
);
} else {
return <Alert type="critical" title="You're not logged in." />;
}
const currentUserId = unwrapOk(result);
if (currentUserId === null) return <NotLoggedIn />;
return (
<>
<UserGreeting userId={currentUserId} />
<div className="mt-4 grid gap-1">
<OAuth2SessionList userId={currentUserId} />
<CompatSsoLoginList userId={currentUserId} />
<BrowserSessionList userId={currentUserId} />
</div>
</>
);
};
export default Home;
+18 -9
View File
@@ -16,7 +16,11 @@ import { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { mapQueryAtom } from "../atoms";
import GraphQLError from "../components/GraphQLError";
import NotFound from "../components/NotFound";
import { graphql } from "../gql";
import { isErr, unwrapErr, unwrapOk } from "../result";
const QUERY = graphql(/* GraphQL */ `
query OAuth2ClientQuery($id: ID!) {
@@ -33,26 +37,31 @@ const QUERY = graphql(/* GraphQL */ `
`);
const oauth2ClientFamily = atomFamily((id: string) => {
const oauth2ClientAtom = atomWithQuery({
const oauth2ClientQueryAtom = atomWithQuery({
query: QUERY,
getVariables: () => ({ id }),
});
const oauth2ClientAtom = mapQueryAtom(
oauth2ClientQueryAtom,
(data) => data?.oauth2Client
);
return oauth2ClientAtom;
});
const OAuth2Client: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(oauth2ClientFamily(id));
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
if (result.data?.oauth2Client) {
return (
<pre>
<code>{JSON.stringify(result.data.oauth2Client, null, 2)}</code>
</pre>
);
}
const oauth2Client = unwrapOk(result);
if (oauth2Client === null) return <NotFound />;
return <>Failed to load OAuth2 client</>;
return (
<pre>
<code>{JSON.stringify(oauth2Client, null, 2)}</code>
</pre>
);
};
export default OAuth2Client;
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const RESULT = Symbol("Result");
const ERR = Symbol("Err");
const OK = Symbol("Ok");
/**
* An `Ok` is a type that represents a successful result.
*/
export type Ok<T> = {
[RESULT]: typeof OK;
[OK]: T;
};
/**
* An `Err` is a type that represents an error.
*/
export type Err<E> = {
[RESULT]: typeof ERR;
[ERR]: E;
};
/**
* A `Result` is a type that represents either an `Ok` or an `Err`.
*/
export type Result<T, E> = Ok<T> | Err<E>;
// Construct an `Ok`
export const ok = <T>(data: T): Ok<T> => ({ [RESULT]: OK, [OK]: data });
// Construct an `Err`
export const err = <E>(error: E): Err<E> => ({
[RESULT]: ERR,
[ERR]: error,
});
// Check if a `Result` is an `Ok`
export const isOk = <T, E>(result: Result<T, E>): result is Ok<T> =>
result[RESULT] === OK;
// Check if a `Result` is an `Err`
export const isErr = <T, E>(result: Result<T, E>): result is Err<E> =>
result[RESULT] === ERR;
// Extract the data from an `Ok`
export const unwrapOk = <T>(result: Ok<T>): T => result[OK];
// Extract the error from an `Err`
export const unwrapErr = <E>(result: Err<E>): E => result[ERR];