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 (
+
+ );
+}
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/*"
]