diff --git a/next.config.mjs b/next.config.mjs index 3faef604..89b9d7a2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -6,6 +6,7 @@ const nextConfig = { transpilePackages: ['@runtipi/shared'], experimental: { serverComponentsExternalPackages: ['bullmq'], + serverActions: true, }, serverRuntimeConfig: { INTERNAL_IP: process.env.INTERNAL_IP, diff --git a/package.json b/package.json index a55958f8..c5248d21 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc6b48fe..e47af464 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..be75a43f --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -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 ( +
+
+ +
+
+
+ Tipi logo +
+
+
{children}
+
+
+
+ ); +} diff --git a/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx b/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx new file mode 100644 index 00000000..7e791357 --- /dev/null +++ b/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx @@ -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(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 verifyTotpMutation.execute({ totpCode, totpSessionId })} />; + } + + return loginMutation.execute({ username: email, password })} />; +} diff --git a/src/client/modules/Auth/containers/LoginContainer/index.ts b/src/app/(auth)/login/components/LoginContainer/index.ts similarity index 100% rename from src/client/modules/Auth/containers/LoginContainer/index.ts rename to src/app/(auth)/login/components/LoginContainer/index.ts diff --git a/src/client/modules/Auth/components/LoginForm/LoginForm.tsx b/src/app/(auth)/login/components/LoginForm/LoginForm.tsx similarity index 93% rename from src/client/modules/Auth/components/LoginForm/LoginForm.tsx rename to src/app/(auth)/login/components/LoginForm/LoginForm.tsx index 5ef317b0..9aae846f 100644 --- a/src/client/modules/Auth/components/LoginForm/LoginForm.tsx +++ b/src/app/(auth)/login/components/LoginForm/LoginForm.tsx @@ -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 }; diff --git a/src/client/modules/Auth/components/LoginForm/index.ts b/src/app/(auth)/login/components/LoginForm/index.ts similarity index 100% rename from src/client/modules/Auth/components/LoginForm/index.ts rename to src/app/(auth)/login/components/LoginForm/index.ts diff --git a/src/client/modules/Auth/components/TotpForm/TotpForm.tsx b/src/app/(auth)/login/components/TotpForm/TotpForm.tsx similarity index 100% rename from src/client/modules/Auth/components/TotpForm/TotpForm.tsx rename to src/app/(auth)/login/components/TotpForm/TotpForm.tsx diff --git a/src/client/modules/Auth/components/TotpForm/index.ts b/src/app/(auth)/login/components/TotpForm/index.ts similarity index 100% rename from src/client/modules/Auth/components/TotpForm/index.ts rename to src/app/(auth)/login/components/TotpForm/index.ts diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..e6857184 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 ; +} diff --git a/src/app/actions/change-locale/change-locale-action.ts b/src/app/actions/change-locale/change-locale-action.ts new file mode 100644 index 00000000..0c493552 --- /dev/null +++ b/src/app/actions/change-locale/change-locale-action.ts @@ -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, + }; +}); diff --git a/src/app/actions/login/login-action.ts b/src/app/actions/login/login-action.ts new file mode 100644 index 00000000..29babbd4 --- /dev/null +++ b/src/app/actions/login/login-action.ts @@ -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); + } +}); diff --git a/src/app/actions/utils/handle-action-error.ts b/src/app/actions/utils/handle-action-error.ts new file mode 100644 index 00000000..78c66a99 --- /dev/null +++ b/src/app/actions/utils/handle-action-error.ts @@ -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, + }, + }; +}; diff --git a/src/app/actions/verify-totp/verify-totp-action.ts b/src/app/actions/verify-totp/verify-totp-action.ts new file mode 100644 index 00000000..377d1f69 --- /dev/null +++ b/src/app/actions/verify-totp/verify-totp-action.ts @@ -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); + } +}); diff --git a/src/app/components/LanguageSelector/LanguageSelector.tsx b/src/app/components/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 00000000..099212d4 --- /dev/null +++ b/src/app/components/LanguageSelector/LanguageSelector.tsx @@ -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(initialLocale); + const { showLabel = false } = props; + const router = useRouter(); + + const { execute } = useAction(changeLocaleAction, { onSuccess: () => router.refresh() }); + + const onChange = (newLocale: Locale) => { + setLocale(newLocale); + execute({ newLocale }); + }; + + return ( + + ); +}; diff --git a/src/app/components/LanguageSelector/LanguageSelectorLabel.tsx b/src/app/components/LanguageSelector/LanguageSelectorLabel.tsx new file mode 100644 index 00000000..f4d77b2f --- /dev/null +++ b/src/app/components/LanguageSelector/LanguageSelectorLabel.tsx @@ -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 ( + + {t('language')}  + + {t('help-translate')} + + + + ); +}; diff --git a/src/app/components/LanguageSelector/index.ts b/src/app/components/LanguageSelector/index.ts new file mode 100644 index 00000000..493cac82 --- /dev/null +++ b/src/app/components/LanguageSelector/index.ts @@ -0,0 +1 @@ +export { LanguageSelector } from './LanguageSelector'; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c9aa5235..e4948db7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + + {children} + + ); diff --git a/src/client/components/ui/Input/Input.tsx b/src/client/components/ui/Input/Input.tsx index 6e4ec968..5504fd18 100644 --- a/src/client/components/ui/Input/Input.tsx +++ b/src/client/components/ui/Input/Input.tsx @@ -25,6 +25,7 @@ export const Input = React.forwardRef(({ onChange, onB )} {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} { - 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(); - - // Assert - expect(screen.getByText('Login')).toBeInTheDocument(); - }); - - it('should have login button disabled if email and password are not provided', () => { - // Arrange - render(); - 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(); - 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(); - - // 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(); - - // 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(); - - // 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(); - - // 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(); - - // 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('/'); - }); - }); -}); diff --git a/src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx b/src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx deleted file mode 100644 index 60af94b6..00000000 --- a/src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx +++ /dev/null @@ -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(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 ( - - {totpSessionId ? ( - verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} /> - ) : ( - - )} - - ); -}; diff --git a/src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx b/src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx deleted file mode 100644 index ea8c5a0e..00000000 --- a/src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx +++ /dev/null @@ -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(); - 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(); - server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: false })); - - await waitFor(() => { - expect(pushFn).toBeCalledWith('/register'); - }); - }); -}); diff --git a/src/client/modules/Auth/pages/LoginPage/LoginPage.tsx b/src/client/modules/Auth/pages/LoginPage/LoginPage.tsx deleted file mode 100644 index 6ca7761a..00000000 --- a/src/client/modules/Auth/pages/LoginPage/LoginPage.tsx +++ /dev/null @@ -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 ; - } - - return ; -}; diff --git a/src/client/modules/Auth/pages/LoginPage/index.ts b/src/client/modules/Auth/pages/LoginPage/index.ts deleted file mode 100644 index 2c983e34..00000000 --- a/src/client/modules/Auth/pages/LoginPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LoginPage } from './LoginPage'; diff --git a/src/lib/get-translator.ts b/src/lib/get-translator.ts new file mode 100644 index 00000000..ebbfe105 --- /dev/null +++ b/src/lib/get-translator.ts @@ -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, + }); +}; diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts new file mode 100644 index 00000000..2f914209 --- /dev/null +++ b/src/lib/safe-action.ts @@ -0,0 +1,3 @@ +import { createSafeActionClient } from 'next-safe-action'; + +export const action = createSafeActionClient(); diff --git a/src/pages/login.tsx b/src/pages/login.tsx deleted file mode 100644 index c04ee789..00000000 --- a/src/pages/login.tsx +++ /dev/null @@ -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: {}, - }); -}; diff --git a/src/server/common/session.helpers.ts b/src/server/common/session.helpers.ts index a0defbc1..86a962cf 100644 --- a/src/server/common/session.helpers.ts +++ b/src/server/common/session.helpers.ts @@ -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}`; diff --git a/src/server/routers/auth/auth.router.ts b/src/server/routers/auth/auth.router.ts index 03c86c63..42be7faf 100644 --- a/src/server/routers/auth/auth.router.ts +++ b/src/server/routers/auth/auth.router.ts @@ -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; diff --git a/src/server/services/auth/auth.service.ts b/src/server/services/auth/auth.service.ts index 6e7b9db0..0b7a4c33 100644 --- a/src/server/services/auth/auth.service.ts +++ b/src/server/services/auth/auth.service.ts @@ -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; }; diff --git a/src/utils/getCurrentLocale.ts b/src/utils/getCurrentLocale.ts new file mode 100644 index 00000000..2ceaf84f --- /dev/null +++ b/src/utils/getCurrentLocale.ts @@ -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')); +}; diff --git a/tsconfig.json b/tsconfig.json index 4ea4e3c4..db94332d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,12 @@ "@/shared/*": [ "./src/shared/*" ], + "@/lib/*": [ + "./src/lib/*" + ], + "@/actions/*": [ + "./src/app/actions/*" + ], "@/tests/*": [ "./tests/*" ]