فهرست منبع

feat: move login page to RSC

Nicolas Meienberger 1 سال پیش
والد
کامیت
32ab0da985
34فایلهای تغییر یافته به همراه359 افزوده شده و 376 حذف شده
  1. 1 0
      next.config.mjs
  2. 2 1
      package.json
  3. 29 11
      pnpm-lock.yaml
  4. 33 0
      src/app/(auth)/layout.tsx
  5. 43 0
      src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx
  6. 0 0
      src/app/(auth)/login/components/LoginContainer/index.ts
  7. 2 2
      src/app/(auth)/login/components/LoginForm/LoginForm.tsx
  8. 0 0
      src/app/(auth)/login/components/LoginForm/index.ts
  9. 0 0
      src/app/(auth)/login/components/TotpForm/TotpForm.tsx
  10. 0 0
      src/app/(auth)/login/components/TotpForm/index.ts
  11. 23 0
      src/app/(auth)/login/page.tsx
  12. 21 0
      src/app/actions/change-locale/change-locale-action.ts
  13. 33 0
      src/app/actions/login/login-action.ts
  14. 20 0
      src/app/actions/utils/handle-action-error.ts
  15. 26 0
      src/app/actions/verify-totp/verify-totp-action.ts
  16. 43 0
      src/app/components/LanguageSelector/LanguageSelector.tsx
  17. 17 0
      src/app/components/LanguageSelector/LanguageSelectorLabel.tsx
  18. 1 0
      src/app/components/LanguageSelector/index.ts
  19. 7 10
      src/app/layout.tsx
  20. 1 0
      src/client/components/ui/Input/Input.tsx
  21. 1 1
      src/client/mocks/handlers.ts
  22. 0 202
      src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx
  23. 0 51
      src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx
  24. 0 38
      src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx
  25. 0 20
      src/client/modules/Auth/pages/LoginPage/LoginPage.tsx
  26. 0 1
      src/client/modules/Auth/pages/LoginPage/index.ts
  27. 20 0
      src/lib/get-translator.ts
  28. 3 0
      src/lib/safe-action.ts
  29. 0 13
      src/pages/login.tsx
  30. 3 4
      src/server/common/session.helpers.ts
  31. 1 8
      src/server/routers/auth/auth.router.ts
  32. 7 14
      src/server/services/auth/auth.service.ts
  33. 16 0
      src/utils/getCurrentLocale.ts
  34. 6 0
      tsconfig.json

+ 1 - 0
next.config.mjs

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

+ 2 - 1
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",

+ 29 - 11
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

+ 33 - 0
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 (
+    <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>
+  );
+}

+ 43 - 0
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<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 })} />;
+}

+ 0 - 0
src/client/modules/Auth/containers/LoginContainer/index.ts → src/app/(auth)/login/components/LoginContainer/index.ts


+ 2 - 2
src/client/modules/Auth/components/LoginForm/LoginForm.tsx → 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 };
 

+ 0 - 0
src/client/modules/Auth/components/LoginForm/index.ts → src/app/(auth)/login/components/LoginForm/index.ts


+ 0 - 0
src/client/modules/Auth/components/TotpForm/TotpForm.tsx → src/app/(auth)/login/components/TotpForm/TotpForm.tsx


+ 0 - 0
src/client/modules/Auth/components/TotpForm/index.ts → src/app/(auth)/login/components/TotpForm/index.ts


+ 23 - 0
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 <LoginContainer />;
+}

+ 21 - 0
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,
+  };
+});

+ 33 - 0
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);
+  }
+});

+ 20 - 0
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,
+    },
+  };
+};

+ 26 - 0
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);
+  }
+});

+ 43 - 0
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<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>
+  );
+};

+ 17 - 0
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 (
+    <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>
+  );
+};

+ 1 - 0
src/app/components/LanguageSelector/index.ts

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

+ 7 - 10
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 (
     <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>
   );

+ 1 - 0
src/client/components/ui/Input/Input.tsx

@@ -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}

+ 1 - 1
src/client/mocks/handlers.ts

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

+ 0 - 202
src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx

@@ -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('/');
-    });
-  });
-});

+ 0 - 51
src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx

@@ -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>
-  );
-};

+ 0 - 38
src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx

@@ -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');
-    });
-  });
-});

+ 0 - 20
src/client/modules/Auth/pages/LoginPage/LoginPage.tsx

@@ -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 />;
-};

+ 0 - 1
src/client/modules/Auth/pages/LoginPage/index.ts

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

+ 20 - 0
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,
+  });
+};

+ 3 - 0
src/lib/safe-action.ts

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

+ 0 - 13
src/pages/login.tsx

@@ -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: {},
-  });
-};

+ 3 - 4
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}`;
 

+ 1 - 8
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;

+ 7 - 14
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;
   };

+ 16 - 0
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'));
+};

+ 6 - 0
tsconfig.json

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