refactor: migrate client auth queries to trpc procedures

This commit is contained in:
Nicolas Meienberger 2022-12-26 22:08:22 +01:00 committed by Nicolas Meienberger
parent 2e13666d80
commit f6a6b85b60
20 changed files with 135 additions and 530 deletions

View file

@ -4,10 +4,10 @@ import React, { useEffect } from 'react';
import clsx from 'clsx';
import ReactTooltip from 'react-tooltip';
import semver from 'semver';
import { useRefreshTokenQuery } from '../../generated/graphql';
import { Header } from '../ui/Header';
import styles from './Layout.module.scss';
import { useSystemStore } from '../../state/systemStore';
import { trpc } from '../../utils/trpc';
interface IProps {
loading?: boolean;
@ -18,17 +18,20 @@ interface IProps {
}
export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
const { data } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
const refreshToken = trpc.auth.refreshToken.useMutation({
onSuccess: (data) => {
if (data?.token) localStorage.setItem('token', data.token);
},
});
useEffect(() => {
refreshToken.mutate();
}, []);
const { version } = useSystemStore();
const defaultVersion = '0.0.0';
const isLatest = semver.gte(version?.current || defaultVersion, version?.latest || defaultVersion);
useEffect(() => {
if (data?.refreshToken?.token) {
localStorage.setItem('token', data.refreshToken.token);
}
}, [data?.refreshToken?.token]);
const renderBreadcrumbs = () => {
if (!breadcrumbs) {
return null;

View file

@ -4,7 +4,6 @@ import React, { useEffect } from 'react';
import clsx from 'clsx';
import ReactTooltip from 'react-tooltip';
import semver from 'semver';
import { useRefreshTokenQuery } from '../../generated/graphql';
import { Header } from '../ui/Header';
import styles from './Layout.module.scss';
import { ErrorPage } from '../ui/ErrorPage';
@ -22,18 +21,21 @@ interface IProps {
}
export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions, loading, error, loadingComponent, data }) => {
const { data: dataRefreshToken } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
const refreshToken = trpc.auth.refreshToken.useMutation({
onSuccess: (d) => {
if (d?.token) localStorage.setItem('token', d.token);
},
});
useEffect(() => {
refreshToken.mutate();
}, []);
const { data: dataVersion } = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
const defaultVersion = '0.0.0';
const isLatest = semver.gte(dataVersion?.current || defaultVersion, dataVersion?.latest || defaultVersion);
useEffect(() => {
if (dataRefreshToken?.refreshToken?.token) {
localStorage.setItem('token', dataRefreshToken.refreshToken.token);
}
}, [dataRefreshToken?.refreshToken?.token]);
const renderBreadcrumbs = () => {
if (!breadcrumbs) {
return null;

View file

@ -1,6 +1,6 @@
import { graphql } from 'msw';
import React from 'react';
import { render, screen, waitFor } from '../../../../../tests/test-utils';
import { getTRPCMock } from '../../../mocks/getTrpcMock';
import { server } from '../../../mocks/server';
import { AuthProvider } from './AuthProvider';
@ -11,13 +11,12 @@ describe('Test: AuthProvider', () => {
<div>Should not render</div>
</AuthProvider>,
);
server.use(getTRPCMock({ path: ['auth', 'me'], type: 'query', response: null }));
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
});
it('should render children if user is logged in', async () => {
server.use(graphql.query('Me', (req, res, ctx) => res(ctx.data({ me: { id: '1' } }))));
render(
<AuthProvider>
<div>Should render</div>
@ -28,7 +27,8 @@ describe('Test: AuthProvider', () => {
});
it('should render register form if app is not configured', async () => {
server.use(graphql.query('Configured', (req, res, ctx) => res(ctx.data({ isConfigured: false }))));
server.use(getTRPCMock({ path: ['auth', 'me'], type: 'query', response: null }));
server.use(getTRPCMock({ path: ['auth', 'isConfigured'], type: 'query', response: false }));
render(
<AuthProvider>
@ -36,7 +36,7 @@ describe('Test: AuthProvider', () => {
</AuthProvider>,
);
await waitFor(() => expect(screen.getByText('Register')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Register your account')).toBeInTheDocument());
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useConfiguredQuery, useMeQuery } from '../../../generated/graphql';
import { LoginContainer } from '../../../modules/Auth/containers/LoginContainer';
import { RegisterContainer } from '../../../modules/Auth/containers/RegisterContainer';
import { trpc } from '../../../utils/trpc';
import { StatusScreen } from '../../StatusScreen';
interface IProps {
@ -9,19 +9,19 @@ interface IProps {
}
export const AuthProvider: React.FC<IProps> = ({ children }) => {
const user = useMeQuery();
const isConfigured = useConfiguredQuery();
const loading = user.loading || isConfigured.loading;
const me = trpc.auth.me.useQuery();
const isConfigured = trpc.auth.isConfigured.useQuery();
const loading = me.isLoading || isConfigured.isLoading;
if (loading && !user.data?.me) {
if (loading) {
return <StatusScreen title="" subtitle="" />;
}
if (user.data?.me) {
if (me.data) {
return children;
}
if (!isConfigured?.data?.isConfigured) {
if (!isConfigured?.data) {
return <RegisterContainer />;
}

View file

@ -1,24 +1,8 @@
import React from 'react';
import { fireEvent, render, renderHook, screen } from '../../../../../tests/test-utils';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
import { useUIStore } from '../../../state/uiStore';
import { Header } from './Header';
const logoutFn = jest.fn();
const reloadFn = jest.fn();
jest.mock('../../../generated/graphql', () => ({
useLogoutMutation: () => [logoutFn],
}));
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
reload: () => reloadFn(),
};
});
describe('Header', () => {
it('renders without crashing', () => {
const { container } = render(<Header />);
@ -63,19 +47,14 @@ describe('Header', () => {
expect(result.current.darkMode).toBe(false);
});
it('Should call the logout mutation on logout', () => {
it('Should remove the token from local storage on logout', async () => {
localStorage.setItem('token', 'token');
const { container } = render(<Header />);
const logoutButton = container.querySelector('[data-tip="Log out"]');
fireEvent.click(logoutButton as Element);
expect(logoutFn).toHaveBeenCalled();
});
it('Should reload the page with next/router on logout', () => {
const { container } = render(<Header />);
const logoutButton = container.querySelector('[data-tip="Log out"]');
fireEvent.click(logoutButton as Element);
expect(reloadFn).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(localStorage.getItem('token')).toBeNull();
});
});
});

View file

@ -2,12 +2,11 @@ import React from 'react';
import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons';
import Image from 'next/image';
import clsx from 'clsx';
import router from 'next/router';
import Link from 'next/link';
import { getUrl } from '../../../core/helpers/url-helpers';
import { useUIStore } from '../../../state/uiStore';
import { NavBar } from '../NavBar';
import { useLogoutMutation } from '../../../generated/graphql';
import { trpc } from '../../../utils/trpc';
interface IProps {
isUpdateAvailable?: boolean;
@ -15,13 +14,13 @@ interface IProps {
export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
const { setDarkMode } = useUIStore();
const [logout] = useLogoutMutation();
const handleLogout = async () => {
await logout();
localStorage.removeItem('token');
router.reload();
};
const utils = trpc.useContext();
const logout = trpc.auth.logout.useMutation({
onSuccess: () => {
localStorage.removeItem('token');
utils.auth.me.invalidate();
},
});
return (
<header className="navbar navbar-expand-md navbar-dark navbar-overlap d-print-none">
@ -67,7 +66,7 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
<div onClick={() => setDarkMode(false)} aria-hidden="true" className="nav-link px-0 hide-theme-light cursor-pointer" data-tip="Light mode">
<IconSun data-testid="icon-sun" size={24} />
</div>
<div onClick={handleLogout} tabIndex={0} onKeyPress={handleLogout} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
<div onClick={() => logout.mutate()} tabIndex={0} onKeyPress={() => logout.mutate()} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
<IconLogout size={24} />
</div>
</div>

View file

@ -131,9 +131,6 @@ export type ListAppsResonse = {
export type Mutation = {
__typename?: 'Mutation';
installApp: App;
login: TokenResponse;
logout: Scalars['Boolean'];
register: TokenResponse;
startApp: App;
stopApp: App;
uninstallApp: App;
@ -145,14 +142,6 @@ export type MutationInstallAppArgs = {
input: AppInputType;
};
export type MutationLoginArgs = {
input: UsernamePasswordInput;
};
export type MutationRegisterArgs = {
input: UsernamePasswordInput;
};
export type MutationStartAppArgs = {
id: Scalars['String'];
};
@ -177,21 +166,13 @@ export type Query = {
__typename?: 'Query';
getApp: App;
installedApps: Array<App>;
isConfigured: Scalars['Boolean'];
listAppsInfo: ListAppsResonse;
me?: Maybe<User>;
refreshToken?: Maybe<TokenResponse>;
};
export type QueryGetAppArgs = {
id: Scalars['String'];
};
export type TokenResponse = {
__typename?: 'TokenResponse';
token: Scalars['String'];
};
export type UpdateInfo = {
__typename?: 'UpdateInfo';
current: Scalars['Float'];
@ -199,41 +180,12 @@ export type UpdateInfo = {
latest: Scalars['Float'];
};
export type User = {
__typename?: 'User';
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
updatedAt: Scalars['DateTime'];
username: Scalars['String'];
};
export type UsernamePasswordInput = {
password: Scalars['String'];
username: Scalars['String'];
};
export type InstallAppMutationVariables = Exact<{
input: AppInputType;
}>;
export type InstallAppMutation = { __typename?: 'Mutation'; installApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type LoginMutationVariables = Exact<{
input: UsernamePasswordInput;
}>;
export type LoginMutation = { __typename?: 'Mutation'; login: { __typename?: 'TokenResponse'; token: string } };
export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
export type RegisterMutationVariables = Exact<{
input: UsernamePasswordInput;
}>;
export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'TokenResponse'; token: string } };
export type StartAppMutationVariables = Exact<{
id: Scalars['String'];
}>;
@ -326,10 +278,6 @@ export type InstalledAppsQuery = {
}>;
};
export type ConfiguredQueryVariables = Exact<{ [key: string]: never }>;
export type ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
export type ListAppsQueryVariables = Exact<{ [key: string]: never }>;
export type ListAppsQuery = {
@ -353,14 +301,6 @@ export type ListAppsQuery = {
};
};
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
export type RefreshTokenQueryVariables = Exact<{ [key: string]: never }>;
export type RefreshTokenQuery = { __typename?: 'Query'; refreshToken?: { __typename?: 'TokenResponse'; token: string } | null };
export const InstallAppDocument = gql`
mutation InstallApp($input: AppInputType!) {
installApp(input: $input) {
@ -396,102 +336,6 @@ export function useInstallAppMutation(baseOptions?: Apollo.MutationHookOptions<I
export type InstallAppMutationHookResult = ReturnType<typeof useInstallAppMutation>;
export type InstallAppMutationResult = Apollo.MutationResult<InstallAppMutation>;
export type InstallAppMutationOptions = Apollo.BaseMutationOptions<InstallAppMutation, InstallAppMutationVariables>;
export const LoginDocument = gql`
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
token
}
}
`;
export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutationVariables>;
/**
* __useLoginMutation__
*
* To run a mutation, you first call `useLoginMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useLoginMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [loginMutation, { data, loading, error }] = useLoginMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useLoginMutation(baseOptions?: Apollo.MutationHookOptions<LoginMutation, LoginMutationVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useMutation<LoginMutation, LoginMutationVariables>(LoginDocument, options);
}
export type LoginMutationHookResult = ReturnType<typeof useLoginMutation>;
export type LoginMutationResult = Apollo.MutationResult<LoginMutation>;
export type LoginMutationOptions = Apollo.BaseMutationOptions<LoginMutation, LoginMutationVariables>;
export const LogoutDocument = gql`
mutation Logout {
logout
}
`;
export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMutationVariables>;
/**
* __useLogoutMutation__
*
* To run a mutation, you first call `useLogoutMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useLogoutMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [logoutMutation, { data, loading, error }] = useLogoutMutation({
* variables: {
* },
* });
*/
export function useLogoutMutation(baseOptions?: Apollo.MutationHookOptions<LogoutMutation, LogoutMutationVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useMutation<LogoutMutation, LogoutMutationVariables>(LogoutDocument, options);
}
export type LogoutMutationHookResult = ReturnType<typeof useLogoutMutation>;
export type LogoutMutationResult = Apollo.MutationResult<LogoutMutation>;
export type LogoutMutationOptions = Apollo.BaseMutationOptions<LogoutMutation, LogoutMutationVariables>;
export const RegisterDocument = gql`
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
token
}
}
`;
export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, RegisterMutationVariables>;
/**
* __useRegisterMutation__
*
* To run a mutation, you first call `useRegisterMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRegisterMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [registerMutation, { data, loading, error }] = useRegisterMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useRegisterMutation(baseOptions?: Apollo.MutationHookOptions<RegisterMutation, RegisterMutationVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useMutation<RegisterMutation, RegisterMutationVariables>(RegisterDocument, options);
}
export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
export const StartAppDocument = gql`
mutation StartApp($id: String!) {
startApp(id: $id) {
@ -789,38 +633,6 @@ export function useInstalledAppsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti
export type InstalledAppsQueryHookResult = ReturnType<typeof useInstalledAppsQuery>;
export type InstalledAppsLazyQueryHookResult = ReturnType<typeof useInstalledAppsLazyQuery>;
export type InstalledAppsQueryResult = Apollo.QueryResult<InstalledAppsQuery, InstalledAppsQueryVariables>;
export const ConfiguredDocument = gql`
query Configured {
isConfigured
}
`;
/**
* __useConfiguredQuery__
*
* To run a query within a React component, call `useConfiguredQuery` and pass it any options that fit your needs.
* When your component renders, `useConfiguredQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useConfiguredQuery({
* variables: {
* },
* });
*/
export function useConfiguredQuery(baseOptions?: Apollo.QueryHookOptions<ConfiguredQuery, ConfiguredQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
}
export function useConfiguredLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ConfiguredQuery, ConfiguredQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
}
export type ConfiguredQueryHookResult = ReturnType<typeof useConfiguredQuery>;
export type ConfiguredLazyQueryHookResult = ReturnType<typeof useConfiguredLazyQuery>;
export type ConfiguredQueryResult = Apollo.QueryResult<ConfiguredQuery, ConfiguredQueryVariables>;
export const ListAppsDocument = gql`
query ListApps {
listAppsInfo {
@ -867,71 +679,3 @@ export function useListAppsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<L
export type ListAppsQueryHookResult = ReturnType<typeof useListAppsQuery>;
export type ListAppsLazyQueryHookResult = ReturnType<typeof useListAppsLazyQuery>;
export type ListAppsQueryResult = Apollo.QueryResult<ListAppsQuery, ListAppsQueryVariables>;
export const MeDocument = gql`
query Me {
me {
id
}
}
`;
/**
* __useMeQuery__
*
* To run a query within a React component, call `useMeQuery` and pass it any options that fit your needs.
* When your component renders, `useMeQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useMeQuery({
* variables: {
* },
* });
*/
export function useMeQuery(baseOptions?: Apollo.QueryHookOptions<MeQuery, MeQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export function useMeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MeQuery, MeQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
export const RefreshTokenDocument = gql`
query RefreshToken {
refreshToken {
token
}
}
`;
/**
* __useRefreshTokenQuery__
*
* To run a query within a React component, call `useRefreshTokenQuery` and pass it any options that fit your needs.
* When your component renders, `useRefreshTokenQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useRefreshTokenQuery({
* variables: {
* },
* });
*/
export function useRefreshTokenQuery(baseOptions?: Apollo.QueryHookOptions<RefreshTokenQuery, RefreshTokenQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<RefreshTokenQuery, RefreshTokenQueryVariables>(RefreshTokenDocument, options);
}
export function useRefreshTokenLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<RefreshTokenQuery, RefreshTokenQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<RefreshTokenQuery, RefreshTokenQueryVariables>(RefreshTokenDocument, options);
}
export type RefreshTokenQueryHookResult = ReturnType<typeof useRefreshTokenQuery>;
export type RefreshTokenLazyQueryHookResult = ReturnType<typeof useRefreshTokenLazyQuery>;
export type RefreshTokenQueryResult = Apollo.QueryResult<RefreshTokenQuery, RefreshTokenQueryVariables>;

View file

@ -1,5 +0,0 @@
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
token
}
}

View file

@ -1,3 +0,0 @@
mutation Logout {
logout
}

View file

@ -1,5 +0,0 @@
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
token
}
}

View file

@ -1,3 +0,0 @@
query Configured {
isConfigured
}

View file

@ -1,5 +0,0 @@
query Me {
me {
id
}
}

View file

@ -1,5 +0,0 @@
query RefreshToken {
refreshToken {
token
}
}

View file

@ -1,75 +1,8 @@
import { graphql } from 'msw';
import { ConfiguredQuery, LoginMutation, LogoutMutationResult, MeQuery, RefreshTokenQuery, RegisterMutation, RegisterMutationVariables, UsernamePasswordInput } from '../generated/graphql';
import { faker } from '@faker-js/faker';
import { getTRPCMock } from './getTrpcMock';
import appHandlers from './handlers/appHandlers';
const graphqlHandlers = [
// Handles a "Login" mutation
graphql.mutation('Login', (req, res, ctx) => {
const { username } = req.variables as UsernamePasswordInput;
sessionStorage.setItem('is-authenticated', username);
const result: LoginMutation = {
login: { token: 'token' },
};
return res(ctx.delay(), ctx.data(result));
}),
// Handles a "Logout" mutation
graphql.mutation('Logout', (_req, res, ctx) => {
sessionStorage.removeItem('is-authenticated');
const result: LogoutMutationResult['data'] = {
logout: true,
};
return res(ctx.delay(), ctx.data(result));
}),
// Handles me query
graphql.query('Me', (_req, res, ctx) => {
const isAuthenticated = sessionStorage.getItem('is-authenticated');
if (!isAuthenticated) {
return res(ctx.errors([{ message: 'Not authenticated' }]));
}
const result: MeQuery = {
me: { id: '1' },
};
return res(ctx.delay(), ctx.data(result));
}),
graphql.query('RefreshToken', (_req, res, ctx) => {
const result: RefreshTokenQuery = {
refreshToken: { token: 'token' },
};
return res(ctx.delay(), ctx.data(result));
}),
graphql.mutation('Register', (req, res, ctx) => {
const {
input: { username },
} = req.variables as RegisterMutationVariables;
const result: RegisterMutation = {
register: { token: 'token' },
};
if (username === 'error@error.com') {
return res(ctx.errors([{ message: 'Username is already taken' }]));
}
return res(ctx.data(result));
}),
appHandlers.listApps,
appHandlers.getApp,
appHandlers.installedApps,
appHandlers.installApp,
graphql.query('Configured', (_req, res, ctx) => {
const result: ConfiguredQuery = {
isConfigured: true,
};
return res(ctx.data(result));
}),
];
const graphqlHandlers = [appHandlers.listApps, appHandlers.getApp, appHandlers.installedApps, appHandlers.installApp];
export const handlers = [
getTRPCMock({
@ -94,5 +27,38 @@ export const handlers = [
type: 'query',
response: { cpu: { load: 0.1 }, disk: { available: 1, total: 2, used: 1 }, memory: { available: 1, total: 2, used: 1 } },
}),
getTRPCMock({
path: ['auth', 'login'],
type: 'mutation',
response: { token: 'token' },
}),
getTRPCMock({
path: ['auth', 'logout'],
type: 'mutation',
response: true,
}),
getTRPCMock({
path: ['auth', 'refreshToken'],
type: 'mutation',
response: { token: 'token' },
}),
getTRPCMock({
path: ['auth', 'register'],
type: 'mutation',
response: { token: 'token' },
}),
getTRPCMock({
path: ['auth', 'me'],
type: 'query',
response: {
id: faker.datatype.number(),
username: faker.internet.userName(),
},
}),
getTRPCMock({
path: ['auth', 'isConfigured'],
type: 'query',
response: true,
}),
...graphqlHandlers,
];

View file

@ -1,8 +1,7 @@
import { faker } from '@faker-js/faker';
import { graphql } from 'msw';
import React from 'react';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { useMeQuery } from '../../../../generated/graphql';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { LoginContainer } from './LoginContainer';
@ -40,21 +39,12 @@ describe('Test: LoginContainer', () => {
expect(loginButton).toBeEnabled();
});
it('should call login mutation on submit', async () => {
it('should add token in localStorage on submit', async () => {
// Arrange
const email = faker.internet.email();
const password = faker.internet.password();
const token = faker.datatype.uuid();
renderHook(() => useMeQuery());
const loginFn = jest.fn();
const fakeInstallHandler = graphql.mutation('Login', (req, res, ctx) => {
loginFn(req.variables.input);
sessionStorage.setItem('is-authenticated', email);
return res(ctx.data({ login: { token } }));
});
server.use(fakeInstallHandler);
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { token } }));
render(<LoginContainer />);
// Act
@ -67,16 +57,15 @@ describe('Test: LoginContainer', () => {
fireEvent.click(loginButton);
// Assert
await waitFor(() => expect(loginFn).toHaveBeenCalledWith({ username: email, password }));
expect(localStorage.getItem('token')).toEqual(token);
await waitFor(() => {
expect(localStorage.getItem('token')).toEqual(token);
});
});
it('should show error message if login fails', async () => {
// Arrange
renderHook(() => useMeQuery());
const { result } = renderHook(() => useToastStore());
const fakeInstallHandler = graphql.mutation('Login', (req, res, ctx) => res(ctx.errors([{ message: 'my big error' }])));
server.use(fakeInstallHandler);
server.use(getTRPCMockError({ path: ['auth', 'login'], type: 'mutation', status: 500, message: 'my big error' }));
render(<LoginContainer />);
// Act

View file

@ -1,51 +1,32 @@
import { useApolloClient } from '@apollo/client';
import React, { useState } from 'react';
import { useLoginMutation } from '../../../../generated/graphql';
import React from 'react';
import { useToastStore } from '../../../../state/toastStore';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { LoginForm } from '../../components/LoginForm';
type FormValues = { email: string; password: string };
export const LoginContainer: React.FC = () => {
const client = useApolloClient();
const [login] = useLoginMutation({});
const [loading, setLoading] = useState(false);
const { addToast } = useToastStore();
const utils = trpc.useContext();
const login = trpc.auth.login.useMutation({
onError: (e) => {
localStorage.removeItem('token');
addToast({ title: 'Login error', description: e.message, status: 'error' });
},
onSuccess: (data) => {
localStorage.setItem('token', data.token);
utils.auth.me.invalidate();
},
});
const handleError = (error: unknown) => {
localStorage.removeItem('token');
if (error instanceof Error) {
addToast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleLogin = async (values: FormValues) => {
try {
setLoading(true);
const { data } = await login({ variables: { input: { username: values.email, password: values.password } } });
if (data?.login?.token) {
localStorage.setItem('token', data.login.token);
}
await client.refetchQueries({ include: ['Me'] });
} catch (error) {
handleError(error);
} finally {
setLoading(false);
}
const handlerSubmit = (values: FormValues) => {
login.mutate({ username: values.email, password: values.password });
};
return (
<AuthFormLayout>
<LoginForm onSubmit={handleLogin} loading={loading} />
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
</AuthFormLayout>
);
};

View file

@ -1,8 +1,7 @@
import { faker } from '@faker-js/faker';
import { graphql } from 'msw';
import React from 'react';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { useMeQuery } from '../../../../generated/graphql';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { RegisterContainer } from './RegisterContainer';
@ -14,20 +13,13 @@ describe('Test: RegisterContainer', () => {
expect(screen.getByText('Register')).toBeInTheDocument();
});
it('should call register mutation on submit', async () => {
it('should add token in localStorage on submit', async () => {
// Arrange
const email = faker.internet.email();
const password = faker.internet.password();
const token = faker.datatype.uuid();
renderHook(() => useMeQuery());
const registerFn = jest.fn();
const fakeRegisterHandler = graphql.mutation('Register', (req, res, ctx) => {
registerFn(req.variables.input);
sessionStorage.setItem('is-authenticated', email);
return res(ctx.data({ register: { token } }));
});
server.use(fakeRegisterHandler);
server.use(getTRPCMock({ path: ['auth', 'register'], type: 'mutation', response: { token }, delay: 100 }));
render(<RegisterContainer />);
// Act
@ -42,8 +34,7 @@ describe('Test: RegisterContainer', () => {
fireEvent.click(registerButton);
// Assert
await waitFor(() => expect(registerFn).toHaveBeenCalledWith({ username: email, password }));
expect(localStorage.getItem('token')).toEqual(token);
await waitFor(() => expect(localStorage.getItem('token')).toEqual(token));
});
it('should show toast if register mutation fails', async () => {
@ -51,10 +42,8 @@ describe('Test: RegisterContainer', () => {
const email = faker.internet.email();
const password = faker.internet.password();
renderHook(() => useMeQuery());
const { result } = renderHook(() => useToastStore());
const fakeRegisterHandler = graphql.mutation('Register', (req, res, ctx) => res(ctx.errors([{ message: 'my big error' }])));
server.use(fakeRegisterHandler);
server.use(getTRPCMockError({ path: ['auth', 'register'], type: 'mutation', status: 500, message: 'my big error' }));
render(<RegisterContainer />);
// Act

View file

@ -1,48 +1,32 @@
import router from 'next/router';
import React, { useState } from 'react';
import { useRegisterMutation } from '../../../../generated/graphql';
import React from 'react';
import { useToastStore } from '../../../../state/toastStore';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { RegisterForm } from '../../components/RegisterForm';
type FormValues = { email: string; password: string };
export const RegisterContainer: React.FC = () => {
const { addToast } = useToastStore();
const [register] = useRegisterMutation({ refetchQueries: ['Me'] });
const [loading, setLoading] = useState(false);
const utils = trpc.useContext();
const register = trpc.auth.register.useMutation({
onError: (e) => {
localStorage.removeItem('token');
addToast({ title: 'Register error', description: e.message, status: 'error' });
},
onSuccess: (data) => {
localStorage.setItem('token', data.token);
utils.auth.me.invalidate();
},
});
const handleError = (error: unknown) => {
if (error instanceof Error) {
addToast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleRegister = async (values: { email: string; password: string }) => {
try {
setLoading(true);
const { data } = await register({ variables: { input: { username: values.email, password: values.password } } });
if (data?.register?.token) {
localStorage.setItem('token', data.register.token);
router.reload();
} else {
setLoading(false);
handleError(new Error('Something went wrong'));
}
} catch (error) {
setLoading(false);
handleError(error);
}
const handlerSubmit = (value: FormValues) => {
register.mutate({ username: value.email, password: value.password });
};
return (
<AuthFormLayout>
<RegisterForm onSubmit={handleRegister} loading={loading} />
<RegisterForm onSubmit={handlerSubmit} loading={register.isLoading} />
</AuthFormLayout>
);
};

View file

@ -1,4 +1,4 @@
import { httpBatchLink } from '@trpc/client';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from '../../server/routers/_app';
@ -22,9 +22,9 @@ export const trpc = createTRPCNext<AppRouter>({
*/
transformer: superjson,
links: [
// loggerLink({
// enabled: (opts) => process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error),
// }),
loggerLink({
enabled: (opts) => process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {

View file

@ -15,14 +15,9 @@ import { SystemStatus, useSystemStore } from '../client/state/systemStore';
function MyApp({ Component, pageProps }: AppProps) {
const { setDarkMode } = useUIStore();
const status = trpc.system.status.useQuery(undefined, { refetchInterval: 5000 });
const version = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
const { setStatus, setVersion } = useSystemStore();
useEffect(() => {
setStatus(status.data?.status || SystemStatus.RUNNING);
}, [status.data?.status, setStatus]);
trpc.system.status.useQuery(undefined, { refetchInterval: 50000, networkMode: 'online', onSuccess: (d) => setStatus(d.status || SystemStatus.RUNNING) });
const version = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
useEffect(() => {
if (version.data) {