mirror of
https://github.com/meienberger/runtipi.git
synced 2024-08-17 08:00:26 +03:00
chore: cleanup trpc related code
This commit is contained in:
parent
34006c680b
commit
36675a67d4
@ -5,11 +5,11 @@ import type { AppStatus } from '@/server/db/schema';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
|
||||
import { AppWithInfo } from '@/client/core/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { AppService } from '@/server/services/apps/apps.service';
|
||||
|
||||
interface IProps {
|
||||
app: AppWithInfo;
|
||||
app: Awaited<ReturnType<AppService['getApp']>>;
|
||||
status?: AppStatus;
|
||||
updateAvailable: boolean;
|
||||
localDomain?: string;
|
||||
|
@ -3,7 +3,6 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppRouterOutput } from '@/server/routers/app/app.router';
|
||||
import { useDisclosure } from '@/client/hooks/useDisclosure';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { installAppAction } from '@/actions/app-actions/install-app-action';
|
||||
@ -16,6 +15,7 @@ import { AppLogo } from '@/components/AppLogo';
|
||||
import { AppStatus } from '@/components/AppStatus';
|
||||
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
|
||||
import { castAppConfig } from '@/lib/helpers/castAppConfig';
|
||||
import { AppService } from '@/server/services/apps/apps.service';
|
||||
import { InstallModal } from '../InstallModal';
|
||||
import { StopModal } from '../StopModal';
|
||||
import { UninstallModal } from '../UninstallModal';
|
||||
@ -26,7 +26,7 @@ import { AppDetailsTabs } from '../AppDetailsTabs';
|
||||
import { FormValues } from '../InstallForm';
|
||||
|
||||
interface IProps {
|
||||
app: AppRouterOutput['getApp'];
|
||||
app: Awaited<ReturnType<AppService['getApp']>>;
|
||||
localDomain?: string;
|
||||
}
|
||||
type OpenType = 'local' | 'domain' | 'local_domain';
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { AppServiceClass } from '@/server/services/apps/apps.service';
|
||||
import { db } from '@/server/db';
|
||||
import React from 'react';
|
||||
import { AppRouterOutput } from '@/server/routers/app/app.router';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
import { AppTile } from './components/AppTile';
|
||||
@ -19,7 +18,7 @@ export default async function Page() {
|
||||
const appsService = new AppServiceClass(db);
|
||||
const installedApps = await appsService.installedApps();
|
||||
|
||||
const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
|
||||
const renderApp = (app: (typeof installedApps)[number]) => {
|
||||
const updateAvailable = Number(app.version) < Number(app.latestVersion);
|
||||
|
||||
if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
|
||||
|
@ -1,4 +0,0 @@
|
||||
import * as Router from '../../server/routers/_app';
|
||||
|
||||
export type App = Omit<Router.RouterOutput['app']['getApp'], 'info'>;
|
||||
export type AppWithInfo = Router.RouterOutput['app']['getApp'];
|
@ -1,109 +0,0 @@
|
||||
import { getTRPCMock } from '@/client/mocks/getTrpcMock';
|
||||
import { server } from '@/client/mocks/server';
|
||||
import { deleteCookie, setCookie, getCookie } from 'cookies-next';
|
||||
import { renderHook, waitFor } from '../../../../tests/test-utils';
|
||||
import { useLocale } from '../useLocale';
|
||||
|
||||
beforeEach(() => {
|
||||
deleteCookie('tipi-locale');
|
||||
});
|
||||
|
||||
describe('test: useLocale()', () => {
|
||||
describe('test: locale', () => {
|
||||
it('should return users locale if logged in', async () => {
|
||||
// arrange
|
||||
const locale = 'fr-FR';
|
||||
// @ts-expect-error - we're mocking the trpc context
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale } }));
|
||||
|
||||
// act
|
||||
const { result } = renderHook(() => useLocale());
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.locale).toEqual(locale);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return cookie locale if not logged in', async () => {
|
||||
// arrange
|
||||
const locale = 'fr-FR';
|
||||
setCookie('tipi-locale', locale);
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
|
||||
|
||||
// act
|
||||
const { result } = renderHook(() => useLocale());
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.locale).toEqual(locale);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return browser locale if not logged in and no cookie', async () => {
|
||||
// arrange
|
||||
const locale = 'fr-FR';
|
||||
jest.spyOn(window.navigator, 'language', 'get').mockReturnValueOnce(locale);
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
|
||||
|
||||
// act
|
||||
const { result } = renderHook(() => useLocale());
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.locale).toEqual(locale);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to english if no locale is found', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
|
||||
// @ts-expect-error - we're mocking window.navigator
|
||||
jest.spyOn(window.navigator, 'language', 'get').mockReturnValueOnce(undefined);
|
||||
|
||||
// act
|
||||
const { result } = renderHook(() => useLocale());
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.locale).toEqual('en-US');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('test: changeLocale()', () => {
|
||||
it('should set the locale in the cookie', async () => {
|
||||
// arrange
|
||||
const locale = 'fr-FR';
|
||||
const { result } = renderHook(() => useLocale());
|
||||
|
||||
// act
|
||||
result.current.changeLocale(locale);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(getCookie('tipi-locale')).toEqual('fr-FR');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the locale in the user profile when logged in', async () => {
|
||||
// arrange
|
||||
const locale = 'en';
|
||||
// @ts-expect-error - we're mocking the trpc context
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'fr-FR' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'changeLocale'], type: 'mutation', response: true }));
|
||||
const { result } = renderHook(() => useLocale());
|
||||
await waitFor(() => {
|
||||
expect(result.current.locale).toEqual('fr-FR');
|
||||
});
|
||||
|
||||
// act
|
||||
result.current.changeLocale(locale);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(getCookie('tipi-locale')).toEqual(locale);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,30 +0,0 @@
|
||||
import { setCookie, getCookie } from 'cookies-next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { trpc } from '@/utils/trpc';
|
||||
import { Locale, getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
|
||||
export const useLocale = () => {
|
||||
const router = useRouter();
|
||||
const me = trpc.auth.me.useQuery();
|
||||
const changeUserLocale = trpc.auth.changeLocale.useMutation();
|
||||
const browserLocale = typeof window !== 'undefined' ? window.navigator.language : undefined;
|
||||
const cookieLocale = getCookie('tipi-locale');
|
||||
|
||||
const locale = String(me.data?.locale || cookieLocale || browserLocale || 'en');
|
||||
const ctx = trpc.useContext();
|
||||
|
||||
const changeLocale = async (l: Locale) => {
|
||||
if (me.data) {
|
||||
await changeUserLocale.mutateAsync({ locale: l });
|
||||
await ctx.invalidate();
|
||||
}
|
||||
|
||||
setCookie('tipi-locale', l, {
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||
});
|
||||
|
||||
router.reload();
|
||||
};
|
||||
|
||||
return { locale: getLocaleFromString(locale), changeLocale };
|
||||
};
|
@ -1,64 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AppStatus } from '@/server/db/schema';
|
||||
import { AppInfo, AppCategory, APP_CATEGORIES } from '@runtipi/shared';
|
||||
import { App, AppWithInfo } from '../../core/types';
|
||||
|
||||
const randomCategory = (): AppCategory[] => {
|
||||
const categories = Object.values(APP_CATEGORIES);
|
||||
const randomIndex = faker.number.int({ min: 0, max: categories.length - 1 });
|
||||
return [categories[randomIndex] as AppCategory];
|
||||
};
|
||||
|
||||
const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
|
||||
const name = faker.lorem.word();
|
||||
return {
|
||||
id: name.toLowerCase(),
|
||||
name,
|
||||
description: faker.lorem.words(),
|
||||
author: faker.lorem.word(),
|
||||
available: true,
|
||||
categories: randomCategory(),
|
||||
form_fields: [],
|
||||
port: faker.number.int({ min: 1000, max: 9999 }),
|
||||
short_desc: faker.lorem.words(),
|
||||
tipi_version: 1,
|
||||
version: faker.system.semver(),
|
||||
source: faker.internet.url(),
|
||||
https: false,
|
||||
no_gui: false,
|
||||
exposable: true,
|
||||
url_suffix: '',
|
||||
force_expose: false,
|
||||
generate_vapid_keys: false,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
type CreateAppEntityParams = {
|
||||
overrides?: Omit<Partial<App>, 'info'>;
|
||||
overridesInfo?: Partial<AppInfo>;
|
||||
status?: AppStatus;
|
||||
};
|
||||
|
||||
export const createAppEntity = (params: CreateAppEntityParams): AppWithInfo => {
|
||||
const { overrides, overridesInfo, status = 'running' } = params;
|
||||
|
||||
const id = faker.lorem.word().toLowerCase();
|
||||
const app = createApp({ id, ...overridesInfo });
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
info: app,
|
||||
config: {},
|
||||
exposed: false,
|
||||
domain: null,
|
||||
version: 1,
|
||||
lastOpened: faker.date.past().toISOString(),
|
||||
numOpened: 0,
|
||||
createdAt: faker.date.past().toISOString(),
|
||||
updatedAt: faker.date.past().toISOString(),
|
||||
latestVersion: 1,
|
||||
latestDockerVersion: '1.0.0',
|
||||
...overrides,
|
||||
};
|
||||
};
|
@ -1,91 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
import SuperJSON from 'superjson';
|
||||
import type { RouterInput, RouterOutput } from '../../server/routers/_app';
|
||||
|
||||
type RpcSuccessResponse<Data> = {
|
||||
id: null;
|
||||
result: { type: 'data'; data: Data };
|
||||
};
|
||||
|
||||
type RpcErrorResponse = {
|
||||
error: {
|
||||
json: {
|
||||
message: string;
|
||||
code: number;
|
||||
data: {
|
||||
code: string;
|
||||
httpStatus: number;
|
||||
stack: string;
|
||||
path: string; // TQuery
|
||||
zodError?: Record<string, string>;
|
||||
tError: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<unknown> => {
|
||||
const response = SuperJSON.serialize(data);
|
||||
|
||||
return {
|
||||
id: null,
|
||||
result: { type: 'data', data: response },
|
||||
};
|
||||
};
|
||||
|
||||
const jsonRpcErrorResponse = (path: string, status: number, message: string, zodError?: Record<string, string>): RpcErrorResponse => ({
|
||||
error: {
|
||||
json: {
|
||||
message,
|
||||
code: -32600,
|
||||
data: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
httpStatus: status,
|
||||
stack: 'Error: Internal Server Error',
|
||||
path,
|
||||
zodError,
|
||||
tError: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getTRPCMock = <
|
||||
K1 extends keyof RouterInput,
|
||||
K2 extends keyof RouterInput[K1], // object itself
|
||||
O extends RouterOutput[K1][K2], // all its keys
|
||||
>(endpoint: {
|
||||
path: [K1, K2];
|
||||
response: O;
|
||||
type?: 'query' | 'mutation';
|
||||
delay?: number;
|
||||
}) => {
|
||||
const fn = endpoint.type === 'mutation' ? rest.post : rest.get;
|
||||
|
||||
const route = `http://localhost:3000/api/trpc/${endpoint.path[0]}.${endpoint.path[1] as string}`;
|
||||
|
||||
return fn(route, (_, res, ctx) => res(ctx.delay(endpoint.delay), ctx.json(jsonRpcSuccessResponse(endpoint.response))));
|
||||
};
|
||||
|
||||
export const getTRPCMockError = <
|
||||
K1 extends keyof RouterInput,
|
||||
K2 extends keyof RouterInput[K1], // object itself
|
||||
>(endpoint: {
|
||||
path: [K1, K2];
|
||||
type?: 'query' | 'mutation';
|
||||
status?: number;
|
||||
message?: string;
|
||||
zodError?: Record<string, string>;
|
||||
}) => {
|
||||
const fn = endpoint.type === 'mutation' ? rest.post : rest.get;
|
||||
|
||||
const route = `http://localhost:3000/api/trpc/${endpoint.path[0]}.${endpoint.path[1] as string}`;
|
||||
|
||||
return fn(route, (_, res, ctx) =>
|
||||
res(ctx.delay(), ctx.json(jsonRpcErrorResponse(`${endpoint.path[0]}.${endpoint.path[1] as string}`, endpoint.status ?? 500, endpoint.message ?? 'Internal Server Error', endpoint.zodError))),
|
||||
);
|
||||
};
|
@ -1,56 +1 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { createAppEntity } from './fixtures/app.fixtures';
|
||||
import { getTRPCMock } from './getTrpcMock';
|
||||
import { createAppConfig } from '../../server/tests/apps.factory';
|
||||
|
||||
export const handlers = [
|
||||
getTRPCMock({
|
||||
path: ['system', 'getVersion'],
|
||||
type: 'query',
|
||||
response: { current: '1.0.0', latest: '1.0.0', body: 'hello' },
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['system', 'restart'],
|
||||
type: 'mutation',
|
||||
response: true,
|
||||
delay: 100,
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['system', 'getSettings'],
|
||||
type: 'query',
|
||||
response: { internalIp: 'localhost', dnsIp: '1.1.1.1', appsRepoUrl: 'https://test.com/test', domain: 'tipi.localhost', localDomain: 'tipi.lan' },
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['system', 'updateSettings'],
|
||||
type: 'mutation',
|
||||
response: undefined,
|
||||
}),
|
||||
// Auth
|
||||
getTRPCMock({
|
||||
path: ['auth', 'me'],
|
||||
type: 'query',
|
||||
response: {
|
||||
totpEnabled: false,
|
||||
id: faker.number.int(),
|
||||
username: faker.internet.userName(),
|
||||
locale: 'en',
|
||||
operator: true,
|
||||
},
|
||||
}),
|
||||
// App
|
||||
getTRPCMock({
|
||||
path: ['app', 'getApp'],
|
||||
type: 'query',
|
||||
response: createAppEntity({ status: 'running' }),
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['app', 'installedApps'],
|
||||
type: 'query',
|
||||
response: [createAppEntity({ status: 'running' }), createAppEntity({ status: 'stopped' })],
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['app', 'listApps'],
|
||||
type: 'query',
|
||||
response: { apps: [createAppConfig({}), createAppConfig({})], total: 2 },
|
||||
}),
|
||||
];
|
||||
export const handlers = [];
|
||||
|
@ -1,96 +0,0 @@
|
||||
import merge from 'lodash.merge';
|
||||
import { deleteCookie, setCookie } from 'cookies-next';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { TipiCache } from '@/server/core/TipiCache';
|
||||
import { getAuthedPageProps, getMessagesPageProps } from '../page-helpers';
|
||||
import englishMessages from '../../messages/en.json';
|
||||
import frenchMessages from '../../messages/fr-FR.json';
|
||||
|
||||
const cache = new TipiCache('page-helpers.test.ts');
|
||||
|
||||
afterAll(async () => {
|
||||
await cache.close();
|
||||
});
|
||||
|
||||
describe('test: getAuthedPageProps()', () => {
|
||||
it('should redirect to /login if there is no user id in session', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { headers: {} } };
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
const { redirect } = await getAuthedPageProps(ctx);
|
||||
|
||||
// assert
|
||||
expect(redirect.destination).toBe('/login');
|
||||
expect(redirect.permanent).toBe(false);
|
||||
});
|
||||
|
||||
it('should return props if there is a user id in session', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { headers: { 'x-session-id': '123' } } };
|
||||
await cache.set('session:123', '456');
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
const { props } = await getAuthedPageProps(ctx);
|
||||
|
||||
// assert
|
||||
expect(props).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('test: getMessagesPageProps()', () => {
|
||||
beforeEach(() => {
|
||||
deleteCookie('tipi-locale');
|
||||
});
|
||||
|
||||
it('should return correct messages if the locale is in the session', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { session: { locale: 'fr' }, headers: {} } };
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
const { props } = await getMessagesPageProps(ctx);
|
||||
|
||||
// assert
|
||||
expect(props.messages).toEqual(merge(frenchMessages, englishMessages));
|
||||
});
|
||||
|
||||
it('should return correct messages if the locale in the cookie', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { session: {}, headers: {} } };
|
||||
setCookie('tipi-locale', 'fr-FR', { req: fromPartial(ctx.req) });
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
const { props } = await getMessagesPageProps(ctx);
|
||||
|
||||
// assert
|
||||
expect(props.messages).toEqual(merge(frenchMessages, englishMessages));
|
||||
});
|
||||
|
||||
it('should return correct messages if the locale is detected from the browser', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { session: {}, headers: { 'accept-language': 'fr-FR' } } };
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
const { props } = await getMessagesPageProps(ctx);
|
||||
|
||||
// assert
|
||||
expect(props.messages).toEqual(merge(frenchMessages, englishMessages));
|
||||
});
|
||||
|
||||
it('should default to english messages if the locale is not found', async () => {
|
||||
// arrange
|
||||
const ctx = { req: { session: {}, headers: {} } };
|
||||
|
||||
// act
|
||||
// @ts-expect-error - we're passing in a partial context
|
||||
const { props } = await getMessagesPageProps(ctx);
|
||||
|
||||
// assert
|
||||
expect(props.messages).toEqual(englishMessages);
|
||||
});
|
||||
});
|
@ -1,42 +0,0 @@
|
||||
import { GetServerSideProps } from 'next';
|
||||
import merge from 'lodash.merge';
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import { getCookie } from 'cookies-next';
|
||||
import { TipiCache } from '@/server/core/TipiCache';
|
||||
|
||||
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
|
||||
const cache = new TipiCache('getAuthedPageProps');
|
||||
const sessionId = ctx.req.headers['x-session-id'];
|
||||
const userId = await cache.get(`session:${sessionId}`);
|
||||
await cache.close();
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/login',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
};
|
||||
|
||||
export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
|
||||
const cookieLocale = getCookie('tipi-locale', { req: ctx.req });
|
||||
const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
|
||||
|
||||
const locale = getLocaleFromString(String(cookieLocale || browserLocale || 'en'));
|
||||
|
||||
const englishMessages = (await import(`../messages/en.json`)).default;
|
||||
const messages = (await import(`../messages/${locale}.json`)).default;
|
||||
const mergedMessages = merge(englishMessages, messages);
|
||||
|
||||
return {
|
||||
props: {
|
||||
messages: mergedMessages,
|
||||
},
|
||||
};
|
||||
};
|
@ -1,41 +0,0 @@
|
||||
import { httpBatchLink, loggerLink } from '@trpc/client';
|
||||
import { createTRPCNext } from '@trpc/next';
|
||||
import superjson from 'superjson';
|
||||
import type { AppRouter } from '../../server/routers/_app';
|
||||
|
||||
/**
|
||||
* Get base url for the current environment
|
||||
*
|
||||
* @returns {string} base url
|
||||
*/
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// browser should use relative path
|
||||
return '';
|
||||
}
|
||||
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
export const trpc = createTRPCNext<AppRouter>({
|
||||
config() {
|
||||
return {
|
||||
transformer: superjson,
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) => process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error),
|
||||
}),
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
fetch(url, options) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
ssr: false,
|
||||
});
|
@ -1,17 +1,12 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import type { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { NextIntlProvider, createTranslator } from 'next-intl';
|
||||
import '../client/styles/global.css';
|
||||
import '../client/styles/global.scss';
|
||||
import 'react-tooltip/dist/react-tooltip.css';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useLocale } from '@/client/hooks/useLocale';
|
||||
import { useUIStore } from '../client/state/uiStore';
|
||||
import { StatusProvider } from '../client/components/hoc/StatusProvider';
|
||||
import { trpc } from '../client/utils/trpc';
|
||||
import { SystemStatus, useSystemStore } from '../client/state/systemStore';
|
||||
|
||||
/**
|
||||
* Next.js App component
|
||||
@ -20,18 +15,7 @@ import { SystemStatus, useSystemStore } from '../client/state/systemStore';
|
||||
* @returns {JSX.Element} - JSX element
|
||||
*/
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const { setDarkMode, setTranslator } = useUIStore();
|
||||
const { setStatus, setVersion, pollStatus } = useSystemStore();
|
||||
const { locale } = useLocale();
|
||||
|
||||
trpc.system.status.useQuery(undefined, { networkMode: 'online', refetchInterval: 2000, onSuccess: (d) => setStatus((d.status as SystemStatus) || 'RUNNING'), enabled: pollStatus });
|
||||
const version = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
|
||||
|
||||
useEffect(() => {
|
||||
if (version.data) {
|
||||
setVersion(version.data);
|
||||
}
|
||||
}, [setVersion, version.data]);
|
||||
const { setDarkMode } = useUIStore();
|
||||
|
||||
// check theme on component mount
|
||||
useEffect(() => {
|
||||
@ -47,28 +31,17 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||
themeCheck();
|
||||
}, [setDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const translator = createTranslator({
|
||||
messages: pageProps.messages,
|
||||
locale,
|
||||
});
|
||||
setTranslator(translator);
|
||||
}, [pageProps.messages, locale, setTranslator]);
|
||||
|
||||
return (
|
||||
<main className="h-100">
|
||||
<NextIntlProvider locale={locale} messages={pageProps.messages}>
|
||||
<Head>
|
||||
<title>Tipi</title>
|
||||
</Head>
|
||||
<StatusProvider>
|
||||
<Component {...pageProps} />
|
||||
</StatusProvider>
|
||||
<Toaster />
|
||||
</NextIntlProvider>
|
||||
<ReactQueryDevtools />
|
||||
<Head>
|
||||
<title>Tipi</title>
|
||||
</Head>
|
||||
<StatusProvider>
|
||||
<Component {...pageProps} />
|
||||
</StatusProvider>
|
||||
<Toaster />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpc.withTRPC(MyApp);
|
||||
export default MyApp;
|
||||
|
@ -1,9 +0,0 @@
|
||||
import * as trpcNext from '@trpc/server/adapters/next';
|
||||
import { createContext } from '../../../server/context';
|
||||
import { mainRouter } from '../../../server/routers/_app';
|
||||
|
||||
// export API handler
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: mainRouter,
|
||||
createContext,
|
||||
});
|
@ -1,46 +0,0 @@
|
||||
import { inferAsyncReturnType } from '@trpc/server';
|
||||
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||
import { TipiCache } from './core/TipiCache/TipiCache';
|
||||
|
||||
type CreateContextOptions = {
|
||||
req: CreateNextContextOptions['req'];
|
||||
res: CreateNextContextOptions['res'];
|
||||
sessionId: string;
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this helper for:
|
||||
* - testing, so we dont have to mock Next.js' req/res
|
||||
* - trpc's `createSSGHelpers` where we don't have req/res
|
||||
*
|
||||
* @param {CreateContextOptions} opts - options
|
||||
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
|
||||
*/
|
||||
const createContextInner = async (opts: CreateContextOptions) => ({
|
||||
...opts,
|
||||
});
|
||||
|
||||
/**
|
||||
* This is the actual context you'll use in your router
|
||||
*
|
||||
* @param {CreateNextContextOptions} opts - options
|
||||
*/
|
||||
export const createContext = async (opts: CreateNextContextOptions) => {
|
||||
const { req, res } = opts;
|
||||
|
||||
const sessionId = req.headers['x-session-id'] as string;
|
||||
|
||||
const cache = new TipiCache('createContext');
|
||||
const userId = await cache.get(`session:${sessionId}`);
|
||||
await cache.close();
|
||||
|
||||
return createContextInner({
|
||||
req,
|
||||
res,
|
||||
sessionId,
|
||||
userId: Number(userId) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export type Context = inferAsyncReturnType<typeof createContext>;
|
@ -1,17 +0,0 @@
|
||||
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import { router } from '../trpc';
|
||||
import { appRouter } from './app/app.router';
|
||||
import { authRouter } from './auth/auth.router';
|
||||
import { systemRouter } from './system/system.router';
|
||||
|
||||
export const mainRouter = router({
|
||||
system: systemRouter,
|
||||
auth: authRouter,
|
||||
app: appRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof mainRouter;
|
||||
|
||||
export type RouterInput = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutput = inferRouterOutputs<AppRouter>;
|
@ -1,27 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { inferRouterOutputs } from '@trpc/server';
|
||||
import { db } from '@/server/db';
|
||||
import { AppServiceClass } from '../../services/apps/apps.service';
|
||||
import { router, protectedProcedure } from '../../trpc';
|
||||
|
||||
export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
|
||||
const AppService = new AppServiceClass(db);
|
||||
|
||||
const formSchema = z.object({}).catchall(z.any());
|
||||
|
||||
export const appRouter = router({
|
||||
getApp: protectedProcedure.input(z.object({ id: z.string() })).query(({ input }) => AppService.getApp(input.id)),
|
||||
startAllApp: protectedProcedure.mutation(AppService.startAllApps),
|
||||
startApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.startApp(input.id)),
|
||||
installApp: protectedProcedure
|
||||
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
|
||||
.mutation(({ input }) => AppService.installApp(input.id, input.form, input.exposed, input.domain)),
|
||||
updateAppConfig: protectedProcedure
|
||||
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
|
||||
.mutation(({ input }) => AppService.updateAppConfig(input.id, input.form, input.exposed, input.domain)),
|
||||
stopApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.stopApp(input.id)),
|
||||
uninstallApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.uninstallApp(input.id)),
|
||||
updateApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.updateApp(input.id)),
|
||||
installedApps: protectedProcedure.query(AppService.installedApps),
|
||||
listApps: protectedProcedure.query(() => AppServiceClass.listApps()),
|
||||
});
|
@ -1,11 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { AuthServiceClass } from '../../services/auth/auth.service';
|
||||
import { router, publicProcedure, protectedProcedure } from '../../trpc';
|
||||
import { db } from '../../db';
|
||||
|
||||
const AuthService = new AuthServiceClass(db);
|
||||
|
||||
export const authRouter = router({
|
||||
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
|
||||
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
|
||||
});
|
@ -1,7 +0,0 @@
|
||||
import { mainRouter } from './_app';
|
||||
|
||||
describe('routers', () => {
|
||||
it('should return a router', () => {
|
||||
expect(mainRouter).toBeDefined();
|
||||
});
|
||||
});
|
@ -1,16 +0,0 @@
|
||||
import { inferRouterOutputs } from '@trpc/server';
|
||||
import { settingsSchema } from '@runtipi/shared';
|
||||
import { router, protectedProcedure, publicProcedure } from '../../trpc';
|
||||
import { SystemServiceClass } from '../../services/system';
|
||||
import * as TipiConfig from '../../core/TipiConfig';
|
||||
|
||||
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
|
||||
const SystemService = new SystemServiceClass();
|
||||
|
||||
export const systemRouter = router({
|
||||
status: publicProcedure.query(SystemServiceClass.status),
|
||||
getVersion: publicProcedure.query(SystemService.getVersion),
|
||||
restart: protectedProcedure.mutation(SystemService.restart),
|
||||
updateSettings: protectedProcedure.input(settingsSchema).mutation(({ input }) => TipiConfig.setSettings(input)),
|
||||
getSettings: protectedProcedure.query(TipiConfig.getSettings),
|
||||
});
|
@ -370,3 +370,5 @@ export class AppServiceClass {
|
||||
.filter(notEmpty);
|
||||
};
|
||||
}
|
||||
|
||||
export type AppService = InstanceType<typeof AppServiceClass>;
|
||||
|
@ -1,72 +0,0 @@
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { typeToFlattenedError, ZodError } from 'zod';
|
||||
import { type Context } from './context';
|
||||
import { AuthQueries } from './queries/auth/auth.queries';
|
||||
import { db } from './db';
|
||||
import { type MessageKey, TranslatedError } from './utils/errors';
|
||||
|
||||
const authQueries = new AuthQueries(db);
|
||||
|
||||
/**
|
||||
* Convert ZodError to a record
|
||||
*
|
||||
* @param {typeToFlattenedError<string>} errors - errors
|
||||
*/
|
||||
function zodErrorsToRecord(errors: typeToFlattenedError<string>) {
|
||||
const record: Record<string, string> = {};
|
||||
Object.entries(errors.fieldErrors).forEach(([key, value]) => {
|
||||
const error = value?.[0];
|
||||
if (error) {
|
||||
record[key] = error;
|
||||
}
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? zodErrorsToRecord(error.cause.flatten()) : null,
|
||||
tError:
|
||||
error.cause instanceof TranslatedError ? { message: error.cause.message as MessageKey, variables: error.cause.variableValues } : { message: error.message as MessageKey, variables: {} },
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
// Base router and procedure helpers
|
||||
export const { router } = t;
|
||||
|
||||
/**
|
||||
* Unprotected procedure
|
||||
*/
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
/**
|
||||
* Reusable middleware to ensure
|
||||
* users are logged in
|
||||
*/
|
||||
const isAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
const { userId } = ctx;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
|
||||
}
|
||||
|
||||
const user = await authQueries.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
|
||||
}
|
||||
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
/**
|
||||
* Protected procedure
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
@ -1,58 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createTRPCReact, httpLink, loggerLink } from '@trpc/react-query';
|
||||
import React, { useState } from 'react';
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { AppRouter } from '../src/server/routers/_app';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>({
|
||||
unstable_overrides: {
|
||||
useMutation: {
|
||||
async onSuccess(opts) {
|
||||
await opts.originalFn();
|
||||
await opts.queryClient.invalidateQueries();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function TRPCTestClientProvider(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
transformer: superjson,
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: () => false,
|
||||
}),
|
||||
httpLink({
|
||||
url: 'http://localhost:3000/api/trpc',
|
||||
fetch: async (input, init?) =>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - isomorphic-fetch is missing the `signal` option
|
||||
fetch(input, {
|
||||
...init,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
}
|
@ -3,22 +3,19 @@ import { render, RenderOptions, renderHook } from '@testing-library/react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { NextIntlProvider } from 'next-intl';
|
||||
import ue from '@testing-library/user-event';
|
||||
import { TRPCTestClientProvider } from './TRPCTestClientProvider';
|
||||
import messages from '../src/client/messages/en.json';
|
||||
|
||||
const userEvent = ue.setup();
|
||||
|
||||
const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<NextIntlProvider locale="en" messages={messages}>
|
||||
<TRPCTestClientProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</TRPCTestClientProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlProvider>
|
||||
);
|
||||
|
||||
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
|
||||
const customRenderHook = (callback: () => any, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
|
||||
const customRenderHook = <Props, Result>(callback: (props: Props) => Result, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { customRender as render };
|
||||
|
Loading…
Reference in New Issue
Block a user