feat: move login page to RSC

This commit is contained in:
Nicolas Meienberger 2023-09-08 17:19:52 +02:00 committed by Nicolas Meienberger
parent a8933e592e
commit 32ab0da985
34 changed files with 359 additions and 376 deletions

View File

@ -6,6 +6,7 @@ const nextConfig = {
transpilePackages: ['@runtipi/shared'],
experimental: {
serverComponentsExternalPackages: ['bullmq'],
serverActions: true,
},
serverRuntimeConfig: {
INTERNAL_IP: process.env.INTERNAL_IP,

View File

@ -13,7 +13,7 @@
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
"dev": "npm run db:migrate && next dev",
"dev:watcher": "pnpm -r --filter cli dev",
"db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
"db:migrate": "NODE_ENV=development dotenv -e .env.local -- tsx ./src/server/run-migrations-dev.ts",
"lint": "next lint",
"lint:fix": "next lint --fix",
"build": "next build",
@ -62,6 +62,7 @@
"lodash.merge": "^4.6.2",
"next": "13.4.19",
"next-intl": "^2.20.0",
"next-safe-action": "^3.0.1",
"pg": "^8.11.1",
"qrcode.react": "^3.1.0",
"react": "18.2.0",

View File

@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@ -94,6 +98,9 @@ importers:
next-intl:
specifier: ^2.20.0
version: 2.20.0(next@13.4.19)(react@18.2.0)
next-safe-action:
specifier: ^3.0.1
version: 3.0.1(next@13.4.19)(react@18.2.0)(zod@3.21.4)
pg:
specifier: ^8.11.1
version: 8.11.1
@ -995,8 +1002,8 @@ packages:
'@commitlint/types': 17.4.4
'@types/node': 20.4.7
chalk: 4.1.2
cosmiconfig: 8.3.4(typescript@4.7.4)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.4)(ts-node@10.9.1)(typescript@4.7.4)
cosmiconfig: 8.3.5(typescript@4.7.4)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.5)(ts-node@10.9.1)(typescript@4.7.4)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@ -5194,6 +5201,7 @@ packages:
/clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
requiresBuild: true
dev: true
/clsx@1.2.1:
@ -5430,7 +5438,7 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: true
/cosmiconfig-typescript-loader@4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.4)(ts-node@10.9.1)(typescript@4.7.4):
/cosmiconfig-typescript-loader@4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.5)(ts-node@10.9.1)(typescript@4.7.4):
resolution: {integrity: sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw==}
engines: {node: '>=v14.21.3'}
peerDependencies:
@ -5440,7 +5448,7 @@ packages:
typescript: '>=4'
dependencies:
'@types/node': 20.4.7
cosmiconfig: 8.3.4(typescript@4.7.4)
cosmiconfig: 8.3.5(typescript@4.7.4)
ts-node: 10.9.1(@types/node@18.6.2)(typescript@4.7.4)
typescript: 4.7.4
dev: true
@ -5456,8 +5464,8 @@ packages:
yaml: 1.10.2
dev: false
/cosmiconfig@8.3.4(typescript@4.7.4):
resolution: {integrity: sha512-SF+2P8+o/PTV05rgsAjDzL4OFdVXAulSfC/L19VaeVT7+tpOOSscCt2QLxDZ+CLxF2WOiq6y1K5asvs8qUJT/Q==}
/cosmiconfig@8.3.5(typescript@4.7.4):
resolution: {integrity: sha512-A5Xry3xfS96wy2qbiLkQLAg4JUrR2wvfybxj6yqLmrUfMAvhS3MZxIP2oQn0grgYIvJqzpeTEWu4vK0t+12NNw==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
@ -8947,7 +8955,7 @@ packages:
acorn: 8.8.2
eslint-visitor-keys: 3.4.1
espree: 9.5.2
semver: 7.5.4
semver: 7.5.3
dev: true
/jsonc-parser@3.2.0:
@ -10024,6 +10032,19 @@ packages:
react: 18.2.0
dev: true
/next-safe-action@3.0.1(next@13.4.19)(react@18.2.0)(zod@3.21.4):
resolution: {integrity: sha512-qQOHz4Z1vnW9fKAl3+nmSoONtX8kvqJBJJ4PkRlkSF8AfFJnYp7PZ5qvtdIBTzxNoQLtM/CyVqlAM/6dCHJ62w==}
engines: {node: '>=16'}
peerDependencies:
next: '>= 13.4.2'
react: '>= 18.2.0'
zod: '>= 3.0.0'
dependencies:
next: 13.4.19(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6)
react: 18.2.0
zod: 3.21.4
dev: false
/next@13.4.19(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6):
resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==}
engines: {node: '>=16.8.0'}
@ -10092,6 +10113,7 @@ packages:
/node-gyp-build-optional-packages@5.0.7:
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==}
hasBin: true
requiresBuild: true
dev: false
optional: true
@ -13190,7 +13212,3 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

33
src/app/(auth)/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import React from 'react';
import Image from 'next/image';
import { getCurrentLocale } from 'src/utils/getCurrentLocale';
import { LanguageSelector } from '../components/LanguageSelector';
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const locale = getCurrentLocale();
return (
<div className="page page-center">
<div className="position-absolute top-0 mt-3 end-0 me-1 pb-4">
<LanguageSelector locale={locale} />
</div>
<div className="container container-tight py-4">
<div className="text-center mb-4">
<Image
alt="Tipi logo"
src="/tipi.png"
height={50}
width={50}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
</div>
<div className="card card-md">
<div className="card-body">{children}</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { useAction } from 'next-safe-action/hook';
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { loginAction } from '@/actions/login/login-action';
import { verifyTotpAction } from '@/actions/verify-totp/verify-totp-action';
import { useRouter } from 'next/navigation';
import { LoginForm } from '../LoginForm';
import { TotpForm } from '../TotpForm';
export function LoginContainer() {
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const router = useRouter();
const loginMutation = useAction(loginAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else if (data.success && data.totpSessionId) {
setTotpSessionId(data.totpSessionId);
} else {
router.push('/dashboard');
}
},
});
const verifyTotpMutation = useAction(verifyTotpAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
router.push('/dashboard');
}
},
});
if (totpSessionId) {
return <TotpForm loading={verifyTotpMutation.isExecuting} onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })} />;
}
return <LoginForm loading={loginMutation.isExecuting} onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })} />;
}

View File

@ -4,8 +4,8 @@ import z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
type FormValues = { email: string; password: string };

View File

@ -0,0 +1,23 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { db } from '@/server/db';
import { LoginContainer } from './components/LoginContainer';
export default async function LoginPage() {
const authQueries = new AuthQueries(db);
const isConfigured = await authQueries.getFirstOperator();
if (!isConfigured) {
redirect('/register');
}
const user = await getUserFromCookie();
if (user) {
redirect('/dashboard');
}
return <LoginContainer />;
}

View File

@ -0,0 +1,21 @@
'use server';
import { z } from 'zod';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import { cookies } from 'next/headers';
import { action } from '@/lib/safe-action';
const input = z.object({
newLocale: z.string(),
});
export const changeLocaleAction = action(input, async ({ newLocale }) => {
const locale = getLocaleFromString(newLocale);
const cookieStore = cookies();
cookieStore.set('tipi-locale', locale);
return {
success: true,
};
});

View File

@ -0,0 +1,33 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({
username: z.string(),
password: z.string(),
});
/**
* Given a username and password, logs in the user and returns a totpSessionId
* if that user has 2FA enabled.
*/
export const loginAction = action(input, async ({ username, password }) => {
try {
const authService = new AuthServiceClass(db);
const { totpSessionId } = await authService.login({ username, password });
if (!totpSessionId) {
revalidatePath('/login');
}
return { totpSessionId, success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,20 @@
import { MessageKey, TranslatedError } from '@/server/utils/errors';
import { getTranslatorFromCookie } from '@/lib/get-translator';
/**
* Given an error, returns a failure object with the translated error message.
*/
export const handleActionError = async (e: unknown) => {
const message = e instanceof Error ? e.message : e;
const errorVariables = e instanceof TranslatedError ? e.variableValues : {};
const translator = await getTranslatorFromCookie();
const messageTranslated = translator(message as MessageKey, errorVariables);
return {
success: false as const,
failure: {
reason: messageTranslated,
},
};
};

View File

@ -0,0 +1,26 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({
totpCode: z.string(),
totpSessionId: z.string(),
});
export const verifyTotpAction = action(input, async ({ totpSessionId, totpCode }) => {
try {
const authService = new AuthServiceClass(db);
await authService.verifyTotp({ totpSessionId, totpCode });
revalidatePath('/login');
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,43 @@
'use client';
import React from 'react';
import { useAction } from 'next-safe-action/hook';
import { LOCALE_OPTIONS, Locale } from '@/shared/internationalization/locales';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { useRouter } from 'next/navigation';
import { changeLocaleAction } from '@/actions/change-locale/change-locale-action';
import { LanguageSelectorLabel } from './LanguageSelectorLabel';
type IProps = {
showLabel?: boolean;
locale: Locale;
};
export const LanguageSelector = (props: IProps) => {
const { locale: initialLocale } = props;
const [locale, setLocale] = React.useState<Locale>(initialLocale);
const { showLabel = false } = props;
const router = useRouter();
const { execute } = useAction(changeLocaleAction, { onSuccess: () => router.refresh() });
const onChange = (newLocale: Locale) => {
setLocale(newLocale);
execute({ newLocale });
};
return (
<Select value={locale} defaultValue="en-US" onValueChange={onChange}>
<SelectTrigger className="mb-3" name="language" label={showLabel && <LanguageSelectorLabel />}>
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LOCALE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@ -0,0 +1,17 @@
import React from 'react';
import { IconExternalLink } from '@tabler/icons-react';
import { useTranslations } from 'next-intl';
export const LanguageSelectorLabel = () => {
const t = useTranslations('settings.settings');
return (
<span>
{t('language')}&nbsp;
<a href="https://crowdin.com/project/runtipi/invite?h=ae594e86cd807bc075310cab20a4aa921693663" target="_blank" rel="noreferrer">
{t('help-translate')}
<IconExternalLink className="ms-1 mb-1" size={16} />
</a>
</span>
);
};

View File

@ -0,0 +1 @@
export { LanguageSelector } from './LanguageSelector';

View File

@ -2,13 +2,13 @@ import React from 'react';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { cookies, headers } from 'next/headers';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import merge from 'lodash.merge';
import { NextIntlClientProvider } from 'next-intl';
import './global.css';
import clsx from 'clsx';
import { Toaster } from 'react-hot-toast';
import { getCurrentLocale } from '../utils/getCurrentLocale';
const inter = Inter({
subsets: ['latin'],
@ -21,13 +21,7 @@ export const metadata: Metadata = {
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies();
const cookieLocale = cookieStore.get('tipi-locale');
const headersList = headers();
const browserLocale = headersList.get('accept-language');
const locale = getLocaleFromString(String(cookieLocale?.value || browserLocale || 'en'));
const locale = getCurrentLocale();
const englishMessages = (await import(`../client/messages/en.json`)).default;
const messages = (await import(`../client/messages/${locale}.json`)).default;
@ -36,7 +30,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang={locale} className={clsx(inter.className, 'border-top-wide border-primary')}>
<NextIntlClientProvider locale={locale} messages={mergedMessages}>
<body>{children}</body>
<body>
{children}
<Toaster />
</body>
</NextIntlClientProvider>
</html>
);

View File

@ -25,6 +25,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
)}
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<input
suppressHydrationWarning
aria-label={name}
role="textbox"
disabled={disabled}

View File

@ -34,7 +34,7 @@ export const handlers = [
getTRPCMock({
path: ['auth', 'login'],
type: 'mutation',
response: {},
response: { sessionId: faker.datatype.uuid() },
}),
getTRPCMock({
path: ['auth', 'logout'],

View File

@ -1,202 +0,0 @@
import { faker } from '@faker-js/faker';
import React from 'react';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { LoginContainer } from './LoginContainer';
const pushFn = jest.fn();
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
useRouter: () => ({
...actualRouter.useRouter(),
push: pushFn,
}),
};
});
beforeEach(() => {
pushFn.mockClear();
});
describe('Test: LoginContainer', () => {
it('should render without error', () => {
// Arrange
render(<LoginContainer />);
// Assert
expect(screen.getByText('Login')).toBeInTheDocument();
});
it('should have login button disabled if email and password are not provided', () => {
// Arrange
render(<LoginContainer />);
const loginButton = screen.getByRole('button', { name: 'Login' });
// Assert
expect(loginButton).toBeDisabled();
});
it('should have login button enabled if email and password are provided', () => {
// Arrange
render(<LoginContainer />);
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
// Act
fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
fireEvent.change(passwordInput, { target: { value: faker.internet.password() } });
// Assert
expect(loginButton).toBeEnabled();
});
it('should redirect to / upon successful login', async () => {
// Arrange
const email = faker.internet.email();
const password = faker.internet.password();
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: {} }));
render(<LoginContainer />);
// Act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
// Assert
await waitFor(() => {
expect(pushFn).toHaveBeenCalledWith('/');
});
});
it('should show error message if login fails', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['auth', 'login'], type: 'mutation', status: 500, message: 'my big error' }));
render(<LoginContainer />);
// Act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
fireEvent.change(passwordInput, { target: { value: 'test' } });
fireEvent.click(loginButton);
// Assert
await waitFor(() => {
expect(screen.getByText(/my big error/)).toBeInTheDocument();
});
});
it('should show totp form if totpSessionId is returned', async () => {
// arrange
const email = faker.internet.email();
const password = faker.internet.password();
const totpSessionId = faker.string.uuid();
server.use(
getTRPCMock({
path: ['auth', 'login'],
type: 'mutation',
response: { totpSessionId },
}),
);
render(<LoginContainer />);
// act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
// assert
await waitFor(() => {
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
});
});
it('should show error message if totp code is invalid', async () => {
// arrange
const email = faker.internet.email();
const password = faker.internet.password();
const totpSessionId = faker.string.uuid();
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
server.use(getTRPCMockError({ path: ['auth', 'verifyTotp'], type: 'mutation', status: 500, message: 'Invalid totp code' }));
render(<LoginContainer />);
// act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
await waitFor(() => {
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
});
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
totpInputs.forEach((input, index) => {
fireEvent.change(input, { target: { value: index } });
});
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(totpSubmitButton);
// assert
await waitFor(() => {
expect(screen.getByText(/Invalid totp code/)).toBeInTheDocument();
});
});
it('should redirect to / if totp is valid', async () => {
// arrange
const email = faker.internet.email();
const password = faker.internet.password();
const totpSessionId = faker.string.uuid();
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
server.use(getTRPCMock({ path: ['auth', 'verifyTotp'], type: 'mutation', response: true }));
render(<LoginContainer />);
// act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
await waitFor(() => {
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
});
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
totpInputs.forEach((input, index) => {
fireEvent.change(input, { target: { value: index } });
});
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(totpSubmitButton);
// assert
await waitFor(() => {
expect(pushFn).toHaveBeenCalledWith('/');
});
});
});

View File

@ -1,51 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/router';
import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { LoginForm } from '../../components/LoginForm';
import { TotpForm } from '../../components/TotpForm';
type FormValues = { email: string; password: string };
export const LoginContainer: React.FC = () => {
const t = useTranslations();
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const router = useRouter();
const utils = trpc.useContext();
const login = trpc.auth.login.useMutation({
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
onSuccess: (data) => {
if (data.totpSessionId) {
setTotpSessionId(data.totpSessionId);
} else {
utils.auth.me.invalidate();
router.push('/');
}
},
});
const verifyTotp = trpc.auth.verifyTotp.useMutation({
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
onSuccess: () => {
utils.auth.me.invalidate();
router.push('/');
},
});
const handlerSubmit = (values: FormValues) => {
login.mutate({ username: values.email, password: values.password });
};
return (
<AuthFormLayout>
{totpSessionId ? (
<TotpForm onSubmit={(o) => verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} />
) : (
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
)}
</AuthFormLayout>
);
};

View File

@ -1,38 +0,0 @@
import React from 'react';
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { LoginPage } from './LoginPage';
const pushFn = jest.fn();
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
useRouter: () => ({
...actualRouter.useRouter(),
push: pushFn,
}),
};
});
describe('Test: LoginPage', () => {
it('should render correctly', async () => {
render(<LoginPage />);
server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: true }));
await waitFor(() => {
expect(screen.getByText('Login')).toBeInTheDocument();
});
});
it('should redirect to register page when isConfigured is false', async () => {
render(<LoginPage />);
server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: false }));
await waitFor(() => {
expect(pushFn).toBeCalledWith('/register');
});
});
});

View File

@ -1,20 +0,0 @@
import { useRouter } from 'next/router';
import React from 'react';
import { StatusScreen } from '../../../../components/StatusScreen';
import { trpc } from '../../../../utils/trpc';
import { LoginContainer } from '../../containers/LoginContainer';
export const LoginPage = () => {
const router = useRouter();
const { data, isLoading } = trpc.auth.isConfigured.useQuery();
if (data === false) {
router.push('/register');
}
if (isLoading) {
return <StatusScreen title="" subtitle="" />;
}
return <LoginContainer />;
};

View File

@ -1 +0,0 @@
export { LoginPage } from './LoginPage';

20
src/lib/get-translator.ts Normal file
View File

@ -0,0 +1,20 @@
import { getLocaleFromString } from '@/shared/internationalization/locales';
import merge from 'lodash.merge';
import { createTranslator } from 'next-intl';
import { cookies } from 'next/headers';
export const getTranslatorFromCookie = async () => {
const cookieStore = cookies();
const cookieLocale = cookieStore.get('tipi-locale');
const locale = getLocaleFromString(cookieLocale?.value);
const englishMessages = (await import(`../client/messages/en.json`)).default;
const messages = (await import(`../client/messages/${locale}.json`)).default;
const mergedMessages = merge(englishMessages, messages);
return createTranslator({
messages: mergedMessages,
locale,
});
};

3
src/lib/safe-action.ts Normal file
View File

@ -0,0 +1,3 @@
import { createSafeActionClient } from 'next-safe-action';
export const action = createSafeActionClient();

View File

@ -1,13 +0,0 @@
import { getMessagesPageProps } from '@/utils/page-helpers';
import merge from 'lodash.merge';
import { GetServerSideProps } from 'next';
export { LoginPage as default } from '../client/modules/Auth/pages/LoginPage';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const messagesProps = await getMessagesPageProps(ctx);
return merge(messagesProps, {
props: {},
});
};

View File

@ -1,5 +1,3 @@
import { setCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { v4 } from 'uuid';
import { cookies } from 'next/headers';
import { TipiCache } from '../core/TipiCache/TipiCache';
@ -13,10 +11,11 @@ export const generateSessionId = (prefix: string) => {
return `${prefix}-${v4()}`;
};
export const setSession = async (sessionId: string, userId: string, req: NextApiRequest, res: NextApiResponse) => {
export const setSession = async (sessionId: string, userId: string) => {
const cache = new TipiCache('setSession');
setCookie(COOKIE_NAME, sessionId, { req, res, maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });
const cookieStore = cookies();
cookieStore.set(COOKIE_NAME, sessionId, { maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });
const sessionKey = `session:${sessionId}`;

View File

@ -6,11 +6,7 @@ import { db } from '../../db';
const AuthService = new AuthServiceClass(db);
export const authRouter = router({
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req, ctx.res)),
logout: protectedProcedure.mutation(async ({ ctx }) => AuthService.logout(ctx.sessionId)),
register: publicProcedure
.input(z.object({ username: z.string(), password: z.string(), locale: z.string() }))
.mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req, ctx.res)),
register: publicProcedure.input(z.object({ username: z.string(), password: z.string(), locale: z.string() })).mutation(async ({ input }) => AuthService.register({ ...input })),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
@ -22,10 +18,7 @@ export const authRouter = router({
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),
// Totp
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req, ctx.res)),
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.userId), password: input.password })),
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.userId), totpCode: input.totpCode })),
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.userId), password: input.password })),
});
export type AuthRouter = typeof authRouter;

View File

@ -7,7 +7,6 @@ import { TranslatedError } from '@/server/utils/errors';
import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
import { generateSessionId, setSession } from '@/server/common/session.helpers';
import { Database } from '@/server/db';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../core/TipiConfig';
import { TipiCache } from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
@ -30,10 +29,8 @@ export class AuthServiceClass {
* Authenticate user with given username and password
*
* @param {UsernamePasswordInput} input - An object containing the user's username and password
* @param {NextApiRequest} req - The Next.js request object
* @param {NextApiResponse} res - The Next.js response object
*/
public login = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
public login = async (input: UsernamePasswordInput) => {
const { password, username } = input;
const user = await this.queries.getUserByUsername(username);
@ -56,9 +53,9 @@ export class AuthServiceClass {
}
const sessionId = uuidv4();
await setSession(sessionId, user.id.toString(), req, res);
await setSession(sessionId, user.id.toString());
return {};
return { sessionId };
};
/**
@ -67,10 +64,8 @@ export class AuthServiceClass {
* @param {object} params - An object containing the TOTP session ID and the TOTP code
* @param {string} params.totpSessionId - The TOTP session ID
* @param {string} params.totpCode - The TOTP code
* @param {NextApiRequest} req - The Next.js request object
* @param {NextApiResponse} res - The Next.js response object
*/
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: NextApiRequest, res: NextApiResponse) => {
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
const { totpSessionId, totpCode } = params;
const cache = new TipiCache('verifyTotp');
const userId = await cache.get(totpSessionId);
@ -98,7 +93,7 @@ export class AuthServiceClass {
}
const sessionId = uuidv4();
await setSession(sessionId, user.id.toString(), req, res);
await setSession(sessionId, user.id.toString());
return true;
};
@ -203,10 +198,8 @@ export class AuthServiceClass {
* Creates a new user with the provided email and password and returns a session token
*
* @param {UsernamePasswordInput} input - An object containing the email and password fields
* @param {NextApiRequest} req - The Next.js request object
* @param {NextApiResponse} res - The Next.js response object
*/
public register = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
public register = async (input: UsernamePasswordInput) => {
const operators = await this.queries.getOperators();
if (operators.length > 0) {
@ -239,7 +232,7 @@ export class AuthServiceClass {
}
const sessionId = uuidv4();
await setSession(sessionId, newUser.id.toString(), req, res);
await setSession(sessionId, newUser.id.toString());
return true;
};

View File

@ -0,0 +1,16 @@
import { getLocaleFromString } from '@/shared/internationalization/locales';
import { cookies, headers } from 'next/headers';
/**
* Get current locale from cookie or browser
* @returns {string} current locale
*/
export const getCurrentLocale = () => {
const cookieStore = cookies();
const cookieLocale = cookieStore.get('tipi-locale');
const headersList = headers();
const browserLocale = headersList.get('accept-language');
return getLocaleFromString(String(cookieLocale?.value || browserLocale || 'en'));
};

View File

@ -18,6 +18,12 @@
"@/shared/*": [
"./src/shared/*"
],
"@/lib/*": [
"./src/lib/*"
],
"@/actions/*": [
"./src/app/actions/*"
],
"@/tests/*": [
"./tests/*"
]