Skip to content

Commit

Permalink
configure redirect paths in the gadget provider and be consistent bet…
Browse files Browse the repository at this point in the history
…ween signed in and signed out
  • Loading branch information
infiton committed Oct 18, 2023
1 parent fbb62fb commit ef78e62
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 22 deletions.
32 changes: 32 additions & 0 deletions packages/react/spec/components/auth/SignedInOrRedirect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ describe("SignedInOrRedirect", () => {
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/?redirectTo=%2F");
});

test("redirects when signed out and a signInPath is provided in the auth context", () => {
const component = (
<h1>
<SignedInOrRedirect>Hello, Jane!</SignedInOrRedirect>
</h1>
);

const { rerender } = render(component, { wrapper: MockClientWrapper(superAuthApi, undefined, { signInPath: "sign-in" }) });

expectMockSignedOutUser();
rerender(component);

expect(mockAssign).toHaveBeenCalledTimes(1);
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/sign-in?redirectTo=%2F");
});

test("redirects when signed out and a signInPath is provided in the auth context and an override path is provided", () => {
const component = (
<h1>
<SignedInOrRedirect path="custom-sign-in">Hello, Jane!</SignedInOrRedirect>
</h1>
);

const { rerender } = render(component, { wrapper: MockClientWrapper(superAuthApi, undefined, { signInPath: "sign-in" }) });

expectMockSignedOutUser();
rerender(component);

expect(mockAssign).toHaveBeenCalledTimes(1);
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/custom-sign-in?redirectTo=%2F");
});

test("redirects when signed in but has no associated user", () => {
const component = (
<h1>
Expand Down
61 changes: 58 additions & 3 deletions packages/react/spec/components/auth/SignedOutOrRedirect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe("SignedOutOrRedirect", () => {
// @ts-expect-error mock
delete window.location;
// @ts-expect-error mock
window.location = { assign: mockAssign, origin: "https://test-app.gadget.app", pathname: "/" };
window.location = { assign: mockAssign, origin: "https://test-app.gadget.app", pathname: "/sign-in" };
});

afterEach(() => {
Expand All @@ -28,7 +28,7 @@ describe("SignedOutOrRedirect", () => {
test("redirects when signed in", () => {
const component = (
<h1>
<SignedOutOrRedirect path="/signed-in">Hello, Jane!</SignedOutOrRedirect>
<SignedOutOrRedirect>Hello, Jane!</SignedOutOrRedirect>
</h1>
);

Expand All @@ -37,14 +37,69 @@ describe("SignedOutOrRedirect", () => {
expectMockSignedInUser();
rerender(component);

expect(mockAssign).toHaveBeenCalledTimes(1);
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/");
});

test("redirects when signed in and a redirectOnSuccessfulSignInPath has been provided", () => {
const component = (
<h1>
<SignedOutOrRedirect>Hello, Jane!</SignedOutOrRedirect>
</h1>
);

const { rerender } = render(component, {
wrapper: MockClientWrapper(superAuthApi, undefined, { redirectOnSuccessfulSignInPath: "/signed-in" }),
});

expectMockSignedInUser();
rerender(component);

expect(mockAssign).toHaveBeenCalledTimes(1);
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/signed-in");
});

test("redirects when signed in and a redirectOnSuccessfulSignInPath has been provided and has been overriden", () => {
const component = (
<h1>
<SignedOutOrRedirect path="/my-custom-path">Hello, Jane!</SignedOutOrRedirect>
</h1>
);

const { rerender } = render(component, {
wrapper: MockClientWrapper(superAuthApi, undefined, { redirectOnSuccessfulSignInPath: "/signed-in" }),
});

expectMockSignedInUser();
rerender(component);

expect(mockAssign).toHaveBeenCalledTimes(1);
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/my-custom-path");
});

test("redirects after signing in to the redirect path", () => {
window.location.search = "?redirectTo=%2Fredirect-me";
const component = (
<h1>
<SignedOutOrRedirect>Hello, Jane!</SignedOutOrRedirect>
</h1>
);

const { rerender } = render(component, {
wrapper: MockClientWrapper(superAuthApi, undefined, { redirectOnSuccessfulSignInPath: "/signed-in" }),
});

expectMockSignedInUser();
rerender(component);

expect(mockAssign).toHaveBeenCalledTimes(1);
expect(mockAssign).toHaveBeenCalledWith("https://test-app.gadget.app/redirect-me");
});

test("renders when signed out", () => {
const component = (
<h1>
<SignedOutOrRedirect path="/signed-in">Hello, Jane!</SignedOutOrRedirect>
<SignedOutOrRedirect>Hello, Jane!</SignedOutOrRedirect>
</h1>
);

Expand Down
25 changes: 13 additions & 12 deletions packages/react/spec/testWrappers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,26 @@ import type { ReactNode } from "react";
import React, { Suspense } from "react";
import type { MockUrqlClient } from "../../api-client-core/spec/mockUrqlClient.js";
import { createMockUrqlClient, mockGraphQLWSClient, mockUrqlClient } from "../../api-client-core/spec/mockUrqlClient.js";
import { Provider } from "../src/GadgetProvider.js";
import { Provider, type GadgetAuthConfiguration } from "../src/GadgetProvider.js";

export const MockClientWrapper = (api: AnyClient, urqlClient?: MockUrqlClient) => (props: { children: ReactNode }) => {
const urql = urqlClient ?? mockUrqlClient;
export const MockClientWrapper =
(api: AnyClient, urqlClient?: MockUrqlClient, auth?: Partial<GadgetAuthConfiguration>) => (props: { children: ReactNode }) => {
const urql = urqlClient ?? mockUrqlClient;

jest.spyOn(api.connection, "currentClient", "get").mockReturnValue(urql);
jest.spyOn(api.connection, "currentClient", "get").mockReturnValue(urql);

return (
<Provider api={api}>
<Suspense fallback={<div>Loading...</div>}>{props.children}</Suspense>
</Provider>
);
};
return (
<Provider api={api} auth={auth}>
<Suspense fallback={<div>Loading...</div>}>{props.children}</Suspense>
</Provider>
);
};

export const MockGraphQLWSClientWrapper = (api: AnyClient) => (props: { children: ReactNode }) => {
export const MockGraphQLWSClientWrapper = (api: AnyClient, auth?: Partial<GadgetAuthConfiguration>) => (props: { children: ReactNode }) => {
jest.replaceProperty(api.connection, "baseSubscriptionClient", mockGraphQLWSClient as any);

return (
<Provider api={api}>
<Provider api={api} auth={auth}>
<Suspense fallback={<div>Loading...</div>}>{props.children}</Suspense>
</Provider>
);
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/GadgetProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface GadgetAuthConfiguration {
signInPath: string;
/** The API identifier of the `User` `signOut` action. Defaults to `signOut` */
signOutActionApiIdentifier: string;
/** The path that users are redirected to after they sign in successfully. */
redirectOnSuccessfulSignInPath: string;
}

/** Provides the api client instance, if present, as well as the Gadget auth configuration for the application. */
Expand Down Expand Up @@ -61,6 +63,7 @@ export interface DeprecatedProviderProps {

const defaultSignInPath = "/";
const defaultSignOutApiIdentifier = "signOut";
const defaultRedirectOnSuccessfulSignInPath = "/";

/**
* Provider wrapper component that passes an api client instance to the other hooks.
Expand Down Expand Up @@ -109,11 +112,13 @@ export function Provider(props: ProviderProps | DeprecatedProviderProps) {

let signInPath = defaultSignInPath;
let signOutActionApiIdentifier = defaultSignOutApiIdentifier;
let redirectOnSuccessfulSignInPath = defaultRedirectOnSuccessfulSignInPath;

if ("auth" in props) {
const { auth } = props;
if (auth?.signInPath) signInPath = auth.signInPath;
if (auth?.signOutActionApiIdentifier) signOutActionApiIdentifier = auth.signOutActionApiIdentifier;
if (auth?.redirectOnSuccessfulSignInPath) redirectOnSuccessfulSignInPath = auth.redirectOnSuccessfulSignInPath;
}

return (
Expand All @@ -124,6 +129,7 @@ export function Provider(props: ProviderProps | DeprecatedProviderProps) {
auth: {
signInPath,
signOutActionApiIdentifier,
redirectOnSuccessfulSignInPath,
},
}}
>
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/auth/SignedInOrRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useAuth } from "./useAuth.js";
/**
* Renders its `children` if the current `Session` is signed in, otherwise redirects the browser to the `signInPath` configured in the `Provider`. Uses `window.location.assign` to perform the redirect.
*/
export const SignedInOrRedirect = (props: { children: ReactNode }) => {
export const SignedInOrRedirect = (props: { path?: string; children: ReactNode }) => {
const [redirected, setRedirected] = useState(false);

const { user, isSignedIn } = useAuth();
Expand All @@ -16,11 +16,12 @@ export const SignedInOrRedirect = (props: { children: ReactNode }) => {
useEffect(() => {
if (auth && !redirected && (!isSignedIn || !user)) {
setRedirected(true);
const redirectUrl = new URL(auth.signInPath, window.location.origin);
const redirectPath = props.path ?? auth.signInPath;
const redirectUrl = new URL(redirectPath, window.location.origin);
redirectUrl.searchParams.set("redirectTo", window.location.pathname);
window.location.assign(redirectUrl.toString());
}
}, [redirected, isSignedIn, auth]);
}, [props.path, redirected, isSignedIn, auth, user]);

if (user && isSignedIn) {
return <>{props.children}</>;
Expand Down
13 changes: 9 additions & 4 deletions packages/react/src/auth/SignedOutOrRedirect.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import type { ReactNode } from "react";
import React, { useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { GadgetConfigurationContext } from "../GadgetProvider.js";
import { useAuth } from "./useAuth.js";

/**
* Renders its `children` if the current `Session` is signed out, otherwise redirects the browser to the `path` prop. Uses `window.location.assign` to perform the redirect.
*/
export const SignedOutOrRedirect = (props: { path: string; children: ReactNode }) => {
export const SignedOutOrRedirect = (props: { path?: string; children: ReactNode }) => {
const [redirected, setRedirected] = useState(false);
const { path, children } = props;

const { user, isSignedIn } = useAuth();
const context = useContext(GadgetConfigurationContext);
const { auth } = context ?? {};

useEffect(() => {
if (!redirected && (isSignedIn || user)) {
setRedirected(true);
const redirectUrl = new URL(path, window.location.origin);
const searchParams = new URLSearchParams(window.location.search);
const redirectPath = searchParams.get("redirectTo") ?? path ?? auth?.redirectOnSuccessfulSignInPath ?? "/";
const redirectUrl = new URL(redirectPath, window.location.origin);
window.location.assign(redirectUrl.toString());
}
}, [redirected, isSignedIn, path, user]);
}, [redirected, isSignedIn, path, user, auth]);

if (!user && !isSignedIn) {
return <>{children}</>;
Expand Down

0 comments on commit ef78e62

Please sign in to comment.