Selaa lähdekoodia

feat: localize authentication flow

Nicolas Meienberger 2 vuotta sitten
vanhempi
commit
fa45be3020

+ 91 - 0
src/client/messages/en.json

@@ -0,0 +1,91 @@
+{
+  "server-messages": {
+    "errors": {
+      "invalid-credentials": "Invalid credentials",
+      "admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
+      "missing-email-or-password": "Missing email or password",
+      "invalid-username": "Invalid username",
+      "user-already-exists": "User already exists",
+      "error-creating-user": "Error creating user",
+      "no-change-password-request": "No change password request found",
+      "operator-not-found": "Operator user not found",
+      "user-not-found": "User not found",
+      "not-allowed-in-demo": "Not allowed in demo mode",
+      "invalid-password": "Invalid password",
+      "invalid-password-length": "Password must be at least 8 characters long",
+      "invalid-locale": "Invalid locale",
+      "totp-session-not-found": "2FA session not found",
+      "totp-not-enabled": "2FA is not enabled for this user",
+      "totp-invalid-code": "Invalid 2FA code",
+      "totp-already-enabled": "2FA is already enabled for this user"
+    },
+    "success": {}
+  },
+  "auth": {
+    "login": {
+      "title": "Login to your account",
+      "submit": "Login"
+    },
+    "totp": {
+      "title": "Two-factor authentication",
+      "instructions": "Enter the code from your authenticator app",
+      "submit": "Confirm"
+    },
+    "register": {
+      "title": "Register your account",
+      "submit": "Register"
+    },
+    "reset-password": {
+      "title": "Reset your password",
+      "submit": "Reset password",
+      "cancel": "Cancel password change request",
+      "instructions": "Run this command on your server and then refresh this page",
+      "success-title": "Password reset",
+      "success": "Your password has been reset. You can now login with your new password. And your email {email}",
+      "back-to-login": "Back to login"
+    },
+    "form": {
+      "email": "Email address",
+      "email-placeholder": "you@example.com",
+      "password": "Password",
+      "password-placeholder": "Enter your password",
+      "password-confirmation": "Confirm password",
+      "password-confirmation-placeholder": "Confirm your password",
+      "forgot": "Forgot password?",
+      "new-password-placeholder": "Your new password",
+      "new-password-confirmation-placeholder": "Confirm your new password",
+      "errors": {
+        "email": {
+          "required": "Email address is required",
+          "email": "Email address is invalid",
+          "invalid": "Email address is invalid"
+        },
+        "password": {
+          "required": "Password is required",
+          "minlength": "Password must be at least 8 characters"
+        },
+        "password-confirmation": {
+          "required": "Password confirmation is required",
+          "minlength": "Password confirmation must be at least 8 characters",
+          "match": "Passwords do not match"
+        }
+      }
+    }
+  },
+  "dashboard": {
+    "title": "Dashboard",
+    "cards": {
+      "disk": {
+        "title": "Disk Space",
+        "subtitle": "Used out of {total} GB"
+      },
+      "memory": {
+        "title": "Memory Used"
+      },
+      "cpu": {
+        "title": "CPU Load",
+        "subtitle": "Uninstall apps to reduce load"
+      }
+    }
+  }
+}

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

@@ -59,6 +59,7 @@ export const handlers = [
       totpEnabled: false,
       totpEnabled: false,
       id: faker.datatype.number(),
       id: faker.datatype.number(),
       username: faker.internet.userName(),
       username: faker.internet.userName(),
+      locale: 'en',
     },
     },
   }),
   }),
   getTRPCMock({
   getTRPCMock({

+ 16 - 5
src/client/modules/Auth/components/LoginForm/LoginForm.tsx

@@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
 import z from 'zod';
 import z from 'zod';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { zodResolver } from '@hookform/resolvers/zod';
 import Link from 'next/link';
 import Link from 'next/link';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { Button } from '../../../../components/ui/Button';
 import { Input } from '../../../../components/ui/Input';
 import { Input } from '../../../../components/ui/Input';
 
 
@@ -19,6 +20,7 @@ interface IProps {
 }
 }
 
 
 export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
 export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
+  const t = useTranslations('auth');
   const {
   const {
     register,
     register,
     handleSubmit,
     handleSubmit,
@@ -35,14 +37,23 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
 
 
   return (
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
-      <h2 className="h2 text-center mb-3">Login to your account</h2>
-      <Input {...register('email')} name="email" label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
+      <h2 className="h2 text-center mb-3">{t('login.title')}</h2>
+      <Input {...register('email')} name="email" label={t('form.email')} error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder={t('form.email-placeholder')} />
       <span className="form-label-description">
       <span className="form-label-description">
-        <Link href="/reset-password">Forgot password?</Link>
+        <Link href="/reset-password">{t('form.forgot')}</Link>
       </span>
       </span>
-      <Input {...register('password')} name="password" label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
+      <Input
+        {...register('password')}
+        name="password"
+        label={t('form.password')}
+        error={errors.password?.message}
+        disabled={loading}
+        type="password"
+        className="mb-3"
+        placeholder={t('form.password-placeholder')}
+      />
       <Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
       <Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
-        Login
+        {t('login.submit')}
       </Button>
       </Button>
     </form>
     </form>
   );
   );

+ 23 - 22
src/client/modules/Auth/components/RegisterForm/RegisterForm.tsx

@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
 import React from 'react';
 import React from 'react';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 import { z } from 'zod';
 import { z } from 'zod';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { Button } from '../../../../components/ui/Button';
 import { Input } from '../../../../components/ui/Input';
 import { Input } from '../../../../components/ui/Input';
 
 
@@ -12,23 +13,23 @@ interface IProps {
 
 
 type FormValues = { email: string; password: string; passwordConfirm: string };
 type FormValues = { email: string; password: string; passwordConfirm: string };
 
 
-const schema = z
-  .object({
-    email: z.string().email(),
-    password: z.string().min(8, 'Password must be at least 8 characters'),
-    passwordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
-  })
-  .superRefine((data, ctx) => {
-    if (data.password !== data.passwordConfirm) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: 'Passwords do not match',
-        path: ['passwordConfirm'],
-      });
-    }
-  });
-
 export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
 export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
+  const t = useTranslations('auth');
+  const schema = z
+    .object({
+      email: z.string().email(),
+      password: z.string().min(8, t('form.errors.password.minlength')),
+      passwordConfirm: z.string().min(8, t('form.errors.password.minlength')),
+    })
+    .superRefine((data, ctx) => {
+      if (data.password !== data.passwordConfirm) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: t('form.errors.password-confirmation.match'),
+          path: ['passwordConfirm'],
+        });
+      }
+    });
   const {
   const {
     register,
     register,
     handleSubmit,
     handleSubmit,
@@ -39,20 +40,20 @@ export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
 
 
   return (
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
-      <h2 className="h2 text-center mb-3">Register your account</h2>
-      <Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
-      <Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
+      <h2 className="h2 text-center mb-3">{t('register.title')}</h2>
+      <Input {...register('email')} label={t('form.email')} error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder={t('form.email-placeholder')} />
+      <Input {...register('password')} label={t('form.password')} error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder={t('form.password-placeholder')} />
       <Input
       <Input
         {...register('passwordConfirm')}
         {...register('passwordConfirm')}
-        label="Confirm password"
+        label={t('form.password-confirmation')}
         error={errors.passwordConfirm?.message}
         error={errors.passwordConfirm?.message}
         disabled={loading}
         disabled={loading}
         type="password"
         type="password"
         className="mb-3"
         className="mb-3"
-        placeholder="Confirm your password"
+        placeholder={t('form.password-confirmation-placeholder')}
       />
       />
       <Button loading={loading} type="submit" className="btn btn-primary w-100">
       <Button loading={loading} type="submit" className="btn btn-primary w-100">
-        Register
+        {t('register.submit')}
       </Button>
       </Button>
     </form>
     </form>
   );
   );

+ 31 - 21
src/client/modules/Auth/components/ResetPasswordForm/ResetPasswordForm.tsx

@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
 import React from 'react';
 import React from 'react';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 import { z } from 'zod';
 import { z } from 'zod';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { Button } from '../../../../components/ui/Button';
 import { Input } from '../../../../components/ui/Input';
 import { Input } from '../../../../components/ui/Input';
 
 
@@ -13,22 +14,23 @@ interface IProps {
 
 
 type FormValues = { password: string; passwordConfirm: string };
 type FormValues = { password: string; passwordConfirm: string };
 
 
-const schema = z
-  .object({
-    password: z.string().min(8, 'Password must be at least 8 characters'),
-    passwordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
-  })
-  .superRefine((data, ctx) => {
-    if (data.password !== data.passwordConfirm) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: 'Passwords do not match',
-        path: ['passwordConfirm'],
-      });
-    }
-  });
-
 export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCancel }) => {
 export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCancel }) => {
+  const t = useTranslations('auth');
+  const schema = z
+    .object({
+      password: z.string().min(8, t('form.errors.password.minlength')),
+      passwordConfirm: z.string().min(8, t('form.errors.password.minlength')),
+    })
+    .superRefine((data, ctx) => {
+      if (data.password !== data.passwordConfirm) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: t('form.errors.password-confirmation.match'),
+          path: ['passwordConfirm'],
+        });
+      }
+    });
+
   const {
   const {
     register,
     register,
     handleSubmit,
     handleSubmit,
@@ -39,22 +41,30 @@ export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCance
 
 
   return (
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
-      <h2 className="h2 text-center mb-3">Reset your password</h2>
-      <Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your new password" />
+      <h2 className="h2 text-center mb-3">{t('reset-password.title')}</h2>
+      <Input
+        {...register('password')}
+        label={t('form.password')}
+        error={errors.password?.message}
+        disabled={loading}
+        type="password"
+        className="mb-3"
+        placeholder={t('form.new-password-placeholder')}
+      />
       <Input
       <Input
         {...register('passwordConfirm')}
         {...register('passwordConfirm')}
-        label="Confirm password"
+        label={t('form.password-confirmation')}
         error={errors.passwordConfirm?.message}
         error={errors.passwordConfirm?.message}
         disabled={loading}
         disabled={loading}
         type="password"
         type="password"
         className="mb-3"
         className="mb-3"
-        placeholder="Confirm your new password"
+        placeholder={t('form.new-password-confirmation-placeholder')}
       />
       />
       <Button loading={loading} type="submit" className="btn btn-primary w-100">
       <Button loading={loading} type="submit" className="btn btn-primary w-100">
-        Reset password
+        {t('reset-password.submit')}
       </Button>
       </Button>
       <Button onClick={onCancel} type="button" className="btn btn-secondary w-100 mt-3">
       <Button onClick={onCancel} type="button" className="btn btn-secondary w-100 mt-3">
-        Cancel password change request
+        {t('reset-password.cancel')}
       </Button>
       </Button>
     </form>
     </form>
   );
   );

+ 5 - 3
src/client/modules/Auth/components/TotpForm/TotpForm.tsx

@@ -1,5 +1,6 @@
 import { Button } from '@/components/ui/Button';
 import { Button } from '@/components/ui/Button';
 import { OtpInput } from '@/components/ui/OtpInput';
 import { OtpInput } from '@/components/ui/OtpInput';
+import { useTranslations } from 'next-intl';
 import React from 'react';
 import React from 'react';
 
 
 type Props = {
 type Props = {
@@ -9,6 +10,7 @@ type Props = {
 
 
 export const TotpForm = (props: Props) => {
 export const TotpForm = (props: Props) => {
   const { onSubmit, loading } = props;
   const { onSubmit, loading } = props;
+  const t = useTranslations('auth');
   const [totpCode, setTotpCode] = React.useState('');
   const [totpCode, setTotpCode] = React.useState('');
 
 
   return (
   return (
@@ -20,11 +22,11 @@ export const TotpForm = (props: Props) => {
       }}
       }}
     >
     >
       <div className="flex items-center justify-center">
       <div className="flex items-center justify-center">
-        <h3 className="">Two-factor authentication</h3>
-        <p className="text-sm text-gray-500">Enter the code from your authenticator app</p>
+        <h3 className="">{t('totp.title')}</h3>
+        <p className="text-sm text-gray-500">{t('totp.instructions')}</p>
         <OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
         <OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
         <Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
         <Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
-          Confirm
+          {t('totp.submit')}
         </Button>
         </Button>
       </div>
       </div>
     </form>
     </form>

+ 12 - 2
src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx

@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 import { toast } from 'react-hot-toast';
 import { toast } from 'react-hot-toast';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
+import { useTranslations } from 'next-intl';
 import { trpc } from '../../../../utils/trpc';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { LoginForm } from '../../components/LoginForm';
 import { LoginForm } from '../../components/LoginForm';
@@ -9,12 +10,17 @@ import { TotpForm } from '../../components/TotpForm';
 type FormValues = { email: string; password: string };
 type FormValues = { email: string; password: string };
 
 
 export const LoginContainer: React.FC = () => {
 export const LoginContainer: React.FC = () => {
+  const t = useTranslations();
   const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
   const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
   const router = useRouter();
   const router = useRouter();
   const utils = trpc.useContext();
   const utils = trpc.useContext();
   const login = trpc.auth.login.useMutation({
   const login = trpc.auth.login.useMutation({
     onError: (e) => {
     onError: (e) => {
-      toast.error(`Login failed: ${e.message}`);
+      let toastMessage = e.message;
+      if (e.data?.translatedError) {
+        toastMessage = t(e.data.translatedError);
+      }
+      toast.error(toastMessage);
     },
     },
     onSuccess: (data) => {
     onSuccess: (data) => {
       if (data.totpSessionId) {
       if (data.totpSessionId) {
@@ -28,7 +34,11 @@ export const LoginContainer: React.FC = () => {
 
 
   const verifyTotp = trpc.auth.verifyTotp.useMutation({
   const verifyTotp = trpc.auth.verifyTotp.useMutation({
     onError: (e) => {
     onError: (e) => {
-      toast.error(`Verification failed: ${e.message}`);
+      let toastMessage = e.message;
+      if (e.data?.translatedError) {
+        toastMessage = t(e.data.translatedError);
+      }
+      toast.error(toastMessage);
     },
     },
     onSuccess: () => {
     onSuccess: () => {
       utils.auth.me.invalidate();
       utils.auth.me.invalidate();

+ 1 - 1
src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx

@@ -75,7 +75,7 @@ describe('Test: RegisterContainer', () => {
 
 
     // Assert
     // Assert
     await waitFor(() => {
     await waitFor(() => {
-      expect(screen.getByText('Registration failed: my big error')).toBeInTheDocument();
+      expect(screen.getByText('my big error')).toBeInTheDocument();
     });
     });
   });
   });
 });
 });

+ 10 - 2
src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.tsx

@@ -1,6 +1,8 @@
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import React from 'react';
 import React from 'react';
 import { toast } from 'react-hot-toast';
 import { toast } from 'react-hot-toast';
+import { useLocale } from '@/client/hooks/useLocale';
+import { useTranslations } from 'next-intl';
 import { trpc } from '../../../../utils/trpc';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { RegisterForm } from '../../components/RegisterForm';
 import { RegisterForm } from '../../components/RegisterForm';
@@ -8,11 +10,17 @@ import { RegisterForm } from '../../components/RegisterForm';
 type FormValues = { email: string; password: string };
 type FormValues = { email: string; password: string };
 
 
 export const RegisterContainer: React.FC = () => {
 export const RegisterContainer: React.FC = () => {
+  const t = useTranslations();
+  const { locale } = useLocale();
   const router = useRouter();
   const router = useRouter();
   const utils = trpc.useContext();
   const utils = trpc.useContext();
   const register = trpc.auth.register.useMutation({
   const register = trpc.auth.register.useMutation({
     onError: (e) => {
     onError: (e) => {
-      toast.error(`Registration failed: ${e.message}`);
+      let toastMessage = e.message;
+      if (e.data?.translatedError) {
+        toastMessage = t(e.data.translatedError);
+      }
+      toast.error(toastMessage);
     },
     },
     onSuccess: () => {
     onSuccess: () => {
       utils.auth.me.invalidate();
       utils.auth.me.invalidate();
@@ -21,7 +29,7 @@ export const RegisterContainer: React.FC = () => {
   });
   });
 
 
   const handlerSubmit = (value: FormValues) => {
   const handlerSubmit = (value: FormValues) => {
-    register.mutate({ username: value.email, password: value.password });
+    register.mutate({ username: value.email, password: value.password, locale });
   };
   };
 
 
   return (
   return (

+ 13 - 7
src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.tsx

@@ -1,6 +1,7 @@
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import React from 'react';
 import React from 'react';
 import { toast } from 'react-hot-toast';
 import { toast } from 'react-hot-toast';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { Button } from '../../../../components/ui/Button';
 import { trpc } from '../../../../utils/trpc';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
@@ -13,14 +14,19 @@ type Props = {
 type FormValues = { password: string };
 type FormValues = { password: string };
 
 
 export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
 export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
+  const t = useTranslations();
   const router = useRouter();
   const router = useRouter();
   const utils = trpc.useContext();
   const utils = trpc.useContext();
   const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
   const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
     onSuccess: () => {
     onSuccess: () => {
       utils.auth.checkPasswordChangeRequest.invalidate();
       utils.auth.checkPasswordChangeRequest.invalidate();
     },
     },
-    onError: (error) => {
-      toast.error(`Failed to reset password ${error.message}`);
+    onError: (e) => {
+      let toastMessage = e.message;
+      if (e.data?.translatedError) {
+        toastMessage = t(e.data.translatedError);
+      }
+      toast.error(toastMessage);
     },
     },
   });
   });
   const cancelRequest = trpc.auth.cancelPasswordChangeRequest.useMutation({
   const cancelRequest = trpc.auth.cancelPasswordChangeRequest.useMutation({
@@ -38,10 +44,10 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
     if (resetPassword.isSuccess) {
     if (resetPassword.isSuccess) {
       return (
       return (
         <>
         <>
-          <h2 className="h2 text-center mb-3">Password reset</h2>
-          <p>Your password has been reset. You can now login with your new password. And your email {resetPassword.data.email}</p>
+          <h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
+          <p>{t('auth.reset-password.success', { email: resetPassword.data.email })}</p>
           <Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
           <Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
-            Back to login
+            {t('auth.reset-password.back-to-login')}
           </Button>
           </Button>
         </>
         </>
       );
       );
@@ -53,8 +59,8 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
 
 
     return (
     return (
       <>
       <>
-        <h2 className="h2 text-center mb-3">Reset your password</h2>
-        <p>Run this command on your server and then refresh this page</p>
+        <h2 className="h2 text-center mb-3">{t('auth.reset-password.title')}</h2>
+        <p>{t('auth.reset-password.instructions')}</p>
         <pre>
         <pre>
           <code>./scripts/reset-password.sh</code>
           <code>./scripts/reset-password.sh</code>
         </pre>
         </pre>

+ 14 - 2
src/client/utils/page-helpers.ts

@@ -1,5 +1,6 @@
 import nookies from 'nookies';
 import nookies from 'nookies';
 import { GetServerSideProps } from 'next';
 import { GetServerSideProps } from 'next';
+import merge from 'lodash.merge';
 import { getLocaleFromString } from '@/shared/internationalization/locales';
 import { getLocaleFromString } from '@/shared/internationalization/locales';
 
 
 export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
 export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
@@ -25,11 +26,22 @@ export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
   const { locale: cookieLocale } = cookies;
   const { locale: cookieLocale } = cookies;
   const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
   const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
 
 
-  const locale = sessionLocale || cookieLocale || browserLocale || 'en';
+  const locale = getLocaleFromString(sessionLocale || cookieLocale || browserLocale || 'en');
+
+  const englishMessages = (await import(`../messages/en.json`)).default;
+  if (locale === 'en') {
+    return {
+      props: {
+        messages: englishMessages,
+      },
+    };
+  }
+
+  const messages = (await import(`../messages/${locale}.json`)).default;
 
 
   return {
   return {
     props: {
     props: {
-      messages: (await import(`../messages/${getLocaleFromString(locale)}.json`)).default,
+      messages: merge(englishMessages, messages),
     },
     },
   };
   };
 };
 };

+ 30 - 32
src/server/services/auth/auth.service.test.ts

@@ -48,13 +48,13 @@ describe('Login', () => {
   });
   });
 
 
   it('Should throw if user does not exist', async () => {
   it('Should throw if user does not exist', async () => {
-    await expect(AuthService.login({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('User not found');
+    await expect(AuthService.login({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.user-not-found');
   });
   });
 
 
   it('Should throw if password is incorrect', async () => {
   it('Should throw if password is incorrect', async () => {
     const email = faker.internet.email();
     const email = faker.internet.email();
     await createUser({ email }, database);
     await createUser({ email }, database);
-    await expect(AuthService.login({ username: email, password: 'wrong' }, fromPartial({}))).rejects.toThrowError('Wrong password');
+    await expect(AuthService.login({ username: email, password: 'wrong' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.invalid-credentials');
   });
   });
 
 
   // TOTP
   // TOTP
@@ -110,7 +110,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
     await TipiCache.set(totpSessionId, user.id.toString());
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' }, fromPartial({}))).rejects.toThrowError('Invalid TOTP');
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-invalid-code');
   });
   });
 
 
   it('should throw if the totpSessionId is invalid', async () => {
   it('should throw if the totpSessionId is invalid', async () => {
@@ -126,7 +126,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
     await TipiCache.set(totpSessionId, user.id.toString());
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp }, fromPartial({}))).rejects.toThrowError('TOTP session not found');
+    await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp }, fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-session-not-found');
   });
   });
 
 
   it('should throw if the user does not exist', async () => {
   it('should throw if the user does not exist', async () => {
@@ -135,7 +135,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, '1234');
     await TipiCache.set(totpSessionId, '1234');
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' }, fromPartial({}))).rejects.toThrowError('User not found');
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.user-not-found');
   });
   });
 
 
   it('should throw if the user totpEnabled is false', async () => {
   it('should throw if the user totpEnabled is false', async () => {
@@ -151,7 +151,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
     await TipiCache.set(totpSessionId, user.id.toString());
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial({}))).rejects.toThrowError('TOTP is not enabled for this user');
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial({}))).rejects.toThrowError('server-messages.errors.totp-not-enabled');
   });
   });
 });
 });
 
 
@@ -209,13 +209,13 @@ describe('Test: getTotpUri', () => {
     expect(userFromDb?.salt).toEqual(salt);
     expect(userFromDb?.salt).toEqual(salt);
   });
   });
 
 
-  it('should thorw an error if user has already configured totp', async () => {
+  it('should throw an error if user has already configured totp', async () => {
     // arrange
     // arrange
     const email = faker.internet.email();
     const email = faker.internet.email();
     const user = await createUser({ email, totpEnabled: true }, database);
     const user = await createUser({ email, totpEnabled: true }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('server-messages.errors.totp-already-enabled');
   });
   });
 
 
   it('should throw an error if the user password is incorrect', async () => {
   it('should throw an error if the user password is incorrect', async () => {
@@ -224,7 +224,7 @@ describe('Test: getTotpUri', () => {
     const user = await createUser({ email }, database);
     const user = await createUser({ email }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('server-messages.errors.invalid-password');
   });
   });
 
 
   it('should throw an error if the user does not exist', async () => {
   it('should throw an error if the user does not exist', async () => {
@@ -232,7 +232,7 @@ describe('Test: getTotpUri', () => {
     const userId = 11;
     const userId = 11;
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.getTotpUri({ userId, password: 'password' })).rejects.toThrowError('User not found');
+    await expect(AuthService.getTotpUri({ userId, password: 'password' })).rejects.toThrowError('server-messages.errors.user-not-found');
   });
   });
 
 
   it('should throw an error if app is in demo mode', async () => {
   it('should throw an error if app is in demo mode', async () => {
@@ -242,7 +242,7 @@ describe('Test: getTotpUri', () => {
     const user = await createUser({ email }, database);
     const user = await createUser({ email }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('2FA is not available in demo mode');
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('server-messages.errors.not-allowed-in-demo');
   });
   });
 });
 });
 
 
@@ -274,7 +274,7 @@ describe('Test: setupTotp', () => {
     const user = await createUser({ email, totpEnabled: true }, database);
     const user = await createUser({ email, totpEnabled: true }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('server-messages.errors.totp-already-enabled');
   });
   });
 
 
   it('should throw if the user does not exist', async () => {
   it('should throw if the user does not exist', async () => {
@@ -282,7 +282,7 @@ describe('Test: setupTotp', () => {
     const userId = 11;
     const userId = 11;
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.setupTotp({ userId, totpCode: '1234' })).rejects.toThrowError('User not found');
+    await expect(AuthService.setupTotp({ userId, totpCode: '1234' })).rejects.toThrowError('server-messages.errors.user-not-found');
   });
   });
 
 
   it('should throw if the otp is invalid', async () => {
   it('should throw if the otp is invalid', async () => {
@@ -295,7 +295,7 @@ describe('Test: setupTotp', () => {
     const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
     const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('server-messages.errors.totp-invalid-code');
   });
   });
 
 
   it('should throw an error if app is in demo mode', async () => {
   it('should throw an error if app is in demo mode', async () => {
@@ -305,7 +305,7 @@ describe('Test: setupTotp', () => {
     const user = await createUser({ email }, database);
     const user = await createUser({ email }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('2FA is not available in demo mode');
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('server-messages.errors.not-allowed-in-demo');
   });
   });
 });
 });
 
 
@@ -332,7 +332,7 @@ describe('Test: disableTotp', () => {
     const user = await createUser({ email, totpEnabled: false }, database);
     const user = await createUser({ email, totpEnabled: false }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
+    await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('server-messages.errors.totp-not-enabled');
   });
   });
 
 
   it('should throw if the user does not exist', async () => {
   it('should throw if the user does not exist', async () => {
@@ -340,7 +340,7 @@ describe('Test: disableTotp', () => {
     const userId = 11;
     const userId = 11;
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.disableTotp({ userId, password: 'password' })).rejects.toThrowError('User not found');
+    await expect(AuthService.disableTotp({ userId, password: 'password' })).rejects.toThrowError('server-messages.errors.user-not-found');
   });
   });
 
 
   it('should throw if the password is invalid', async () => {
   it('should throw if the password is invalid', async () => {
@@ -349,7 +349,7 @@ describe('Test: disableTotp', () => {
     const user = await createUser({ email, totpEnabled: true }, database);
     const user = await createUser({ email, totpEnabled: true }, database);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
+    await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('server-messages.errors.invalid-password');
   });
   });
 });
 });
 
 
@@ -387,9 +387,7 @@ describe('Register', () => {
 
 
     // Act & Assert
     // Act & Assert
     await createUser({ email, operator: true }, database);
     await createUser({ email, operator: true }, database);
-    await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError(
-      'There is already an admin user. Please login to create a new user from the admin panel.',
-    );
+    await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.admin-already-exists');
   });
   });
 
 
   it('Should throw if user already exists', async () => {
   it('Should throw if user already exists', async () => {
@@ -398,15 +396,15 @@ describe('Register', () => {
 
 
     // Act & Assert
     // Act & Assert
     await createUser({ email, operator: false }, database);
     await createUser({ email, operator: false }, database);
-    await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError('User already exists');
+    await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.user-already-exists');
   });
   });
 
 
   it('Should throw if email is not provided', async () => {
   it('Should throw if email is not provided', async () => {
-    await expect(AuthService.register({ username: '', password: 'test' }, fromPartial({}))).rejects.toThrowError('Missing email or password');
+    await expect(AuthService.register({ username: '', password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.missing-email-or-password');
   });
   });
 
 
   it('Should throw if password is not provided', async () => {
   it('Should throw if password is not provided', async () => {
-    await expect(AuthService.register({ username: faker.internet.email(), password: '' }, fromPartial({}))).rejects.toThrowError('Missing email or password');
+    await expect(AuthService.register({ username: faker.internet.email(), password: '' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.missing-email-or-password');
   });
   });
 
 
   it('Password is correctly hashed', async () => {
   it('Password is correctly hashed', async () => {
@@ -423,7 +421,7 @@ describe('Register', () => {
   });
   });
 
 
   it('Should throw if email is invalid', async () => {
   it('Should throw if email is invalid', async () => {
-    await expect(AuthService.register({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('Invalid username');
+    await expect(AuthService.register({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('server-messages.errors.invalid-username');
   });
   });
 
 
   it('should throw if db fails to insert user', async () => {
   it('should throw if db fails to insert user', async () => {
@@ -434,7 +432,7 @@ describe('Register', () => {
     const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
     const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
 
 
     // Act & Assert
     // Act & Assert
-    await expect(newAuthService.register({ username: email, password: 'test' }, fromPartial(req))).rejects.toThrowError('Error creating user');
+    await expect(newAuthService.register({ username: email, password: 'test' }, fromPartial(req))).rejects.toThrowError('server-messages.errors.error-creating-user');
   });
   });
 });
 });
 
 
@@ -544,7 +542,7 @@ describe('Test: changeOperatorPassword', () => {
     fs.__createMockFiles({});
     fs.__createMockFiles({});
 
 
     // Act & Assert
     // Act & Assert
-    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('No password change request found');
+    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('server-messages.errors.no-change-password-request');
   });
   });
 
 
   it('should throw if there is no operator user', async () => {
   it('should throw if there is no operator user', async () => {
@@ -556,7 +554,7 @@ describe('Test: changeOperatorPassword', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
 
     // Act & Assert
     // Act & Assert
-    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
+    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('server-messages.errors.operator-not-found');
   });
   });
 
 
   it('should reset totpSecret and totpEnabled if totp is enabled', async () => {
   it('should reset totpSecret and totpEnabled if totp is enabled', async () => {
@@ -639,7 +637,7 @@ describe('Test: changePassword', () => {
     const newPassword = faker.internet.password();
     const newPassword = faker.internet.password();
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.changePassword({ userId: 1, newPassword, currentPassword: 'password' })).rejects.toThrowError('User not found');
+    await expect(AuthService.changePassword({ userId: 1, newPassword, currentPassword: 'password' })).rejects.toThrowError('server-messages.errors.user-not-found');
   });
   });
 
 
   it('should throw if the password is incorrect', async () => {
   it('should throw if the password is incorrect', async () => {
@@ -649,7 +647,7 @@ describe('Test: changePassword', () => {
     const newPassword = faker.internet.password();
     const newPassword = faker.internet.password();
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'wrongpassword' })).rejects.toThrowError('Current password is invalid');
+    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'wrongpassword' })).rejects.toThrowError('server-messages.errors.invalid-password');
   });
   });
 
 
   it('should throw if password is less than 8 characters', async () => {
   it('should throw if password is less than 8 characters', async () => {
@@ -659,7 +657,7 @@ describe('Test: changePassword', () => {
     const newPassword = faker.internet.password(7);
     const newPassword = faker.internet.password(7);
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('Password must be at least 8 characters');
+    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('server-messages.errors.invalid-password-length');
   });
   });
 
 
   it('should throw if instance is in demo mode', async () => {
   it('should throw if instance is in demo mode', async () => {
@@ -670,7 +668,7 @@ describe('Test: changePassword', () => {
     const newPassword = faker.internet.password();
     const newPassword = faker.internet.password();
 
 
     // act & assert
     // act & assert
-    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('Changing password is not allowed in demo mode');
+    await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('server-messages.errors.not-allowed-in-demo');
   });
   });
 
 
   it('should delete all sessions for the user', async () => {
   it('should delete all sessions for the user', async () => {

+ 32 - 39
src/server/services/auth/auth.service.ts

@@ -5,11 +5,12 @@ import { generateSessionId } from '@/server/common/get-server-auth-session';
 import { NodePgDatabase } from 'drizzle-orm/node-postgres';
 import { NodePgDatabase } from 'drizzle-orm/node-postgres';
 import { AuthQueries } from '@/server/queries/auth/auth.queries';
 import { AuthQueries } from '@/server/queries/auth/auth.queries';
 import { Context } from '@/server/context';
 import { Context } from '@/server/context';
+import { TranslatedError } from '@/server/utils/errors';
+import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
 import { getConfig } from '../../core/TipiConfig';
 import { getConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 import TipiCache from '../../core/TipiCache';
 import { fileExists, unlinkFile } from '../../common/fs.helpers';
 import { fileExists, unlinkFile } from '../../common/fs.helpers';
 import { decrypt, encrypt } from '../../utils/encryption';
 import { decrypt, encrypt } from '../../utils/encryption';
-import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
 
 
 type UsernamePasswordInput = {
 type UsernamePasswordInput = {
   username: string;
   username: string;
@@ -29,20 +30,19 @@ export class AuthServiceClass {
    *
    *
    * @param {UsernamePasswordInput} input - An object containing the user's username and password
    * @param {UsernamePasswordInput} input - An object containing the user's username and password
    * @param {Request} req - The Next.js request object
    * @param {Request} req - The Next.js request object
-   * @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
    */
    */
   public login = async (input: UsernamePasswordInput, req: Context['req']) => {
   public login = async (input: UsernamePasswordInput, req: Context['req']) => {
     const { password, username } = input;
     const { password, username } = input;
     const user = await this.queries.getUserByUsername(username);
     const user = await this.queries.getUserByUsername(username);
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     const isPasswordValid = await argon2.verify(user.password, password);
     const isPasswordValid = await argon2.verify(user.password, password);
 
 
     if (!isPasswordValid) {
     if (!isPasswordValid) {
-      throw new Error('Wrong password');
+      throw new TranslatedError('server-messages.errors.invalid-credentials');
     }
     }
 
 
     if (user.totpEnabled) {
     if (user.totpEnabled) {
@@ -64,31 +64,30 @@ export class AuthServiceClass {
    * @param {string} params.totpSessionId - The TOTP session ID
    * @param {string} params.totpSessionId - The TOTP session ID
    * @param {string} params.totpCode - The TOTP code
    * @param {string} params.totpCode - The TOTP code
    * @param {Request} req - The Next.js request object
    * @param {Request} req - The Next.js request object
-   * @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
    */
    */
   public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: Context['req']) => {
   public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: Context['req']) => {
     const { totpSessionId, totpCode } = params;
     const { totpSessionId, totpCode } = params;
     const userId = await TipiCache.get(totpSessionId);
     const userId = await TipiCache.get(totpSessionId);
 
 
     if (!userId) {
     if (!userId) {
-      throw new Error('TOTP session not found');
+      throw new TranslatedError('server-messages.errors.totp-session-not-found');
     }
     }
 
 
     const user = await this.queries.getUserById(Number(userId));
     const user = await this.queries.getUserById(Number(userId));
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     if (!user.totpEnabled || !user.totpSecret || !user.salt) {
     if (!user.totpEnabled || !user.totpSecret || !user.salt) {
-      throw new Error('TOTP is not enabled for this user');
+      throw new TranslatedError('server-messages.errors.totp-not-enabled');
     }
     }
 
 
     const totpSecret = decrypt(user.totpSecret, user.salt);
     const totpSecret = decrypt(user.totpSecret, user.salt);
     const isValid = TotpAuthenticator.check(totpCode, totpSecret);
     const isValid = TotpAuthenticator.check(totpCode, totpSecret);
 
 
     if (!isValid) {
     if (!isValid) {
-      throw new Error('Invalid TOTP code');
+      throw new TranslatedError('server-messages.errors.totp-invalid-code');
     }
     }
 
 
     req.session.userId = user.id;
     req.session.userId = user.id;
@@ -102,11 +101,10 @@ export class AuthServiceClass {
    * @param {object} params - An object containing the userId and the user's password
    * @param {object} params - An object containing the userId and the user's password
    * @param {number} params.userId - The user's ID
    * @param {number} params.userId - The user's ID
    * @param {string} params.password - The user's password
    * @param {string} params.password - The user's password
-   * @returns {Promise<{uri: string, key: string}>} - A promise that resolves to an object containing the TOTP URI and the secret key
    */
    */
   public getTotpUri = async (params: { userId: number; password: string }) => {
   public getTotpUri = async (params: { userId: number; password: string }) => {
     if (getConfig().demoMode) {
     if (getConfig().demoMode) {
-      throw new Error('2FA is not available in demo mode');
+      throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
     }
     }
 
 
     const { userId, password } = params;
     const { userId, password } = params;
@@ -114,16 +112,16 @@ export class AuthServiceClass {
     const user = await this.queries.getUserById(userId);
     const user = await this.queries.getUserById(userId);
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     const isPasswordValid = await argon2.verify(user.password, password);
     const isPasswordValid = await argon2.verify(user.password, password);
     if (!isPasswordValid) {
     if (!isPasswordValid) {
-      throw new Error('Invalid password');
+      throw new TranslatedError('server-messages.errors.invalid-password');
     }
     }
 
 
     if (user.totpEnabled) {
     if (user.totpEnabled) {
-      throw new Error('TOTP is already enabled for this user');
+      throw new TranslatedError('server-messages.errors.totp-already-enabled');
     }
     }
 
 
     let { salt } = user;
     let { salt } = user;
@@ -144,25 +142,25 @@ export class AuthServiceClass {
 
 
   public setupTotp = async (params: { userId: number; totpCode: string }) => {
   public setupTotp = async (params: { userId: number; totpCode: string }) => {
     if (getConfig().demoMode) {
     if (getConfig().demoMode) {
-      throw new Error('2FA is not available in demo mode');
+      throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
     }
     }
 
 
     const { userId, totpCode } = params;
     const { userId, totpCode } = params;
     const user = await this.queries.getUserById(userId);
     const user = await this.queries.getUserById(userId);
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     if (user.totpEnabled || !user.totpSecret || !user.salt) {
     if (user.totpEnabled || !user.totpSecret || !user.salt) {
-      throw new Error('TOTP is already enabled for this user');
+      throw new TranslatedError('server-messages.errors.totp-already-enabled');
     }
     }
 
 
     const totpSecret = decrypt(user.totpSecret, user.salt);
     const totpSecret = decrypt(user.totpSecret, user.salt);
     const isValid = TotpAuthenticator.check(totpCode, totpSecret);
     const isValid = TotpAuthenticator.check(totpCode, totpSecret);
 
 
     if (!isValid) {
     if (!isValid) {
-      throw new Error('Invalid TOTP code');
+      throw new TranslatedError('server-messages.errors.totp-invalid-code');
     }
     }
 
 
     await this.queries.updateUser(userId, { totpEnabled: true });
     await this.queries.updateUser(userId, { totpEnabled: true });
@@ -176,16 +174,16 @@ export class AuthServiceClass {
     const user = await this.queries.getUserById(userId);
     const user = await this.queries.getUserById(userId);
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     if (!user.totpEnabled) {
     if (!user.totpEnabled) {
-      throw new Error('TOTP is not enabled for this user');
+      throw new TranslatedError('server-messages.errors.totp-not-enabled');
     }
     }
 
 
     const isPasswordValid = await argon2.verify(user.password, password);
     const isPasswordValid = await argon2.verify(user.password, password);
     if (!isPasswordValid) {
     if (!isPasswordValid) {
-      throw new Error('Invalid password');
+      throw new TranslatedError('server-messages.errors.invalid-password');
     }
     }
 
 
     await this.queries.updateUser(userId, { totpEnabled: false, totpSecret: null });
     await this.queries.updateUser(userId, { totpEnabled: false, totpSecret: null });
@@ -198,31 +196,29 @@ export class AuthServiceClass {
    *
    *
    * @param {UsernamePasswordInput} input - An object containing the email and password fields
    * @param {UsernamePasswordInput} input - An object containing the email and password fields
    * @param {Request} req - The Next.js request object
    * @param {Request} req - The Next.js request object
-   * @returns {Promise<{token: string}>} - An object containing the session token
-   * @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
    */
    */
   public register = async (input: UsernamePasswordInput, req: Context['req']) => {
   public register = async (input: UsernamePasswordInput, req: Context['req']) => {
     const operators = await this.queries.getOperators();
     const operators = await this.queries.getOperators();
 
 
     if (operators.length > 0) {
     if (operators.length > 0) {
-      throw new Error('There is already an admin user. Please login to create a new user from the admin panel.');
+      throw new TranslatedError('server-messages.errors.admin-already-exists');
     }
     }
 
 
     const { password, username } = input;
     const { password, username } = input;
     const email = username.trim().toLowerCase();
     const email = username.trim().toLowerCase();
 
 
     if (!username || !password) {
     if (!username || !password) {
-      throw new Error('Missing email or password');
+      throw new TranslatedError('server-messages.errors.missing-email-or-password');
     }
     }
 
 
     if (username.length < 3 || !validator.isEmail(email)) {
     if (username.length < 3 || !validator.isEmail(email)) {
-      throw new Error('Invalid username');
+      throw new TranslatedError('server-messages.errors.invalid-username');
     }
     }
 
 
     const user = await this.queries.getUserByUsername(email);
     const user = await this.queries.getUserByUsername(email);
 
 
     if (user) {
     if (user) {
-      throw new Error('User already exists');
+      throw new TranslatedError('server-messages.errors.user-already-exists');
     }
     }
 
 
     const hash = await argon2.hash(password);
     const hash = await argon2.hash(password);
@@ -230,7 +226,7 @@ export class AuthServiceClass {
     const newUser = await this.queries.createUser({ username: email, password: hash, operator: true, locale: getLocaleFromString(input.locale) });
     const newUser = await this.queries.createUser({ username: email, password: hash, operator: true, locale: getLocaleFromString(input.locale) });
 
 
     if (!newUser) {
     if (!newUser) {
-      throw new Error('Error creating user');
+      throw new TranslatedError('server-messages.errors.error-creating-user');
     }
     }
 
 
     req.session.userId = newUser.id;
     req.session.userId = newUser.id;
@@ -243,7 +239,6 @@ export class AuthServiceClass {
    * Retrieves the user with the provided ID
    * Retrieves the user with the provided ID
    *
    *
    * @param {number|undefined} userId - The user ID to retrieve
    * @param {number|undefined} userId - The user ID to retrieve
-   * @returns {Promise<{id: number, username: string} | null>} - An object containing the user's id and email, or null if the user is not found
    */
    */
   public me = async (userId: number | undefined) => {
   public me = async (userId: number | undefined) => {
     if (!userId) return null;
     if (!userId) return null;
@@ -287,12 +282,10 @@ export class AuthServiceClass {
    *
    *
    * @param {object} params - An object containing the new password
    * @param {object} params - An object containing the new password
    * @param {string} params.newPassword - The new password
    * @param {string} params.newPassword - The new password
-   * @returns {Promise<string>} - The username of the operator user
-   * @throws {Error} - If the operator user is not found or if there is no password change request
    */
    */
   public changeOperatorPassword = async (params: { newPassword: string }) => {
   public changeOperatorPassword = async (params: { newPassword: string }) => {
     if (!AuthServiceClass.checkPasswordChangeRequest()) {
     if (!AuthServiceClass.checkPasswordChangeRequest()) {
-      throw new Error('No password change request found');
+      throw new TranslatedError('server-messages.errors.no-change-password-request');
     }
     }
 
 
     const { newPassword } = params;
     const { newPassword } = params;
@@ -300,7 +293,7 @@ export class AuthServiceClass {
     const user = await this.queries.getFirstOperator();
     const user = await this.queries.getFirstOperator();
 
 
     if (!user) {
     if (!user) {
-      throw new Error('Operator user not found');
+      throw new TranslatedError('server-messages.errors.operator-not-found');
     }
     }
 
 
     const hash = await argon2.hash(newPassword);
     const hash = await argon2.hash(newPassword);
@@ -359,7 +352,7 @@ export class AuthServiceClass {
 
 
   public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => {
   public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => {
     if (getConfig().demoMode) {
     if (getConfig().demoMode) {
-      throw new Error('Changing password is not allowed in demo mode');
+      throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
     }
     }
 
 
     const { currentPassword, newPassword, userId } = params;
     const { currentPassword, newPassword, userId } = params;
@@ -367,17 +360,17 @@ export class AuthServiceClass {
     const user = await this.queries.getUserById(userId);
     const user = await this.queries.getUserById(userId);
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     const valid = await argon2.verify(user.password, currentPassword);
     const valid = await argon2.verify(user.password, currentPassword);
 
 
     if (!valid) {
     if (!valid) {
-      throw new Error('Current password is invalid');
+      throw new TranslatedError('server-messages.errors.invalid-password');
     }
     }
 
 
     if (newPassword.length < 8) {
     if (newPassword.length < 8) {
-      throw new Error('Password must be at least 8 characters long');
+      throw new TranslatedError('server-messages.errors.invalid-password-length');
     }
     }
 
 
     const hash = await argon2.hash(newPassword);
     const hash = await argon2.hash(newPassword);
@@ -400,13 +393,13 @@ export class AuthServiceClass {
     const isLocaleValid = Locales.includes(locale);
     const isLocaleValid = Locales.includes(locale);
 
 
     if (!isLocaleValid) {
     if (!isLocaleValid) {
-      throw new Error('Invalid locale');
+      throw new TranslatedError('server-messages.errors.invalid-locale');
     }
     }
 
 
     const user = await this.queries.getUserById(userId);
     const user = await this.queries.getUserById(userId);
 
 
     if (!user) {
     if (!user) {
-      throw new Error('User not found');
+      throw new TranslatedError('server-messages.errors.user-not-found');
     }
     }
 
 
     await this.queries.updateUser(user.id, { locale });
     await this.queries.updateUser(user.id, { locale });

+ 4 - 1
src/server/trpc.ts

@@ -1,10 +1,11 @@
 import { initTRPC, TRPCError } from '@trpc/server';
 import { initTRPC, TRPCError } from '@trpc/server';
 import superjson from 'superjson';
 import superjson from 'superjson';
 import { typeToFlattenedError, ZodError } from 'zod';
 import { typeToFlattenedError, ZodError } from 'zod';
+import { Locale } from '@/shared/internationalization/locales';
 import { type Context } from './context';
 import { type Context } from './context';
 import { AuthQueries } from './queries/auth/auth.queries';
 import { AuthQueries } from './queries/auth/auth.queries';
 import { db } from './db';
 import { db } from './db';
-import { Locale } from '@/shared/internationalization/locales';
+import { MessageKey, TranslatedError } from './utils/errors';
 
 
 const authQueries = new AuthQueries(db);
 const authQueries = new AuthQueries(db);
 
 
@@ -25,6 +26,7 @@ export function zodErrorsToRecord(errors: typeToFlattenedError<string>) {
 
 
   return record;
   return record;
 }
 }
+
 const t = initTRPC.context<Context>().create({
 const t = initTRPC.context<Context>().create({
   transformer: superjson,
   transformer: superjson,
   errorFormatter({ shape, error }) {
   errorFormatter({ shape, error }) {
@@ -33,6 +35,7 @@ const t = initTRPC.context<Context>().create({
       data: {
       data: {
         ...shape.data,
         ...shape.data,
         zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? zodErrorsToRecord(error.cause.flatten()) : null,
         zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? zodErrorsToRecord(error.cause.flatten()) : null,
+        translatedError: error.cause instanceof TranslatedError ? (error.cause.message as MessageKey) : null,
       },
       },
     };
     };
   },
   },

+ 13 - 0
src/server/utils/errors.ts

@@ -0,0 +1,13 @@
+import { createTranslator } from 'next-intl';
+import messages from '../../client/messages/en.json';
+
+const t = createTranslator({ locale: 'en', messages });
+export type MessageKey = Parameters<typeof t>[0];
+
+export class TranslatedError extends Error {
+  constructor(message: MessageKey) {
+    super(message);
+
+    this.name = 'TranslatedError';
+  }
+}

+ 8 - 4
tests/test-utils.tsx

@@ -1,13 +1,17 @@
 import React, { FC, ReactElement } from 'react';
 import React, { FC, ReactElement } from 'react';
 import { render, RenderOptions, renderHook } from '@testing-library/react';
 import { render, RenderOptions, renderHook } from '@testing-library/react';
 import { Toaster } from 'react-hot-toast';
 import { Toaster } from 'react-hot-toast';
+import { NextIntlProvider } from 'next-intl';
 import { TRPCTestClientProvider } from './TRPCTestClientProvider';
 import { TRPCTestClientProvider } from './TRPCTestClientProvider';
+import messages from '../src/client/messages/en.json';
 
 
 const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
 const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
-  <TRPCTestClientProvider>
-    {children}
-    <Toaster />
-  </TRPCTestClientProvider>
+  <NextIntlProvider locale="en" messages={messages}>
+    <TRPCTestClientProvider>
+      {children}
+      <Toaster />
+    </TRPCTestClientProvider>
+  </NextIntlProvider>
 );
 );
 
 
 const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
 const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });