Переглянути джерело

feat: translate settings page

Nicolas Meienberger 2 роки тому
батько
коміт
148391b9c0

+ 34 - 23
src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.tsx

@@ -7,33 +7,37 @@ import { z } from 'zod';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { useRouter } from 'next/router';
 import { toast } from 'react-hot-toast';
+import { useTranslations } from 'next-intl';
+import type { MessageKey } from '@/server/utils/errors';
 
-const schema = z
-  .object({
-    currentPassword: z.string().min(1),
-    newPassword: z.string().min(8, 'Password must be at least 8 characters'),
-    newPasswordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
-  })
-  .superRefine((data, ctx) => {
-    if (data.newPassword !== data.newPasswordConfirm) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: 'Passwords do not match',
-        path: ['newPasswordConfirm'],
-      });
-    }
-  });
+export const ChangePasswordForm = () => {
+  const globalT = useTranslations();
+  const t = useTranslations('settings.security');
 
-type FormValues = z.infer<typeof schema>;
+  const schema = z
+    .object({
+      currentPassword: z.string().min(1),
+      newPassword: z.string().min(8, t('form.password-length')),
+      newPasswordConfirm: z.string().min(8, t('form.password-length')),
+    })
+    .superRefine((data, ctx) => {
+      if (data.newPassword !== data.newPasswordConfirm) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: t('form.password-match'),
+          path: ['newPasswordConfirm'],
+        });
+      }
+    });
+  type FormValues = z.infer<typeof schema>;
 
-export const ChangePasswordForm = () => {
   const router = useRouter();
   const changePassword = trpc.auth.changePassword.useMutation({
     onError: (e) => {
-      toast.error(`Error changing password: ${e.message}`);
+      toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
     onSuccess: () => {
-      toast.success('Password successfully changed');
+      toast.success(t('password-change-success'));
       router.push('/');
     },
   });
@@ -52,11 +56,18 @@ export const ChangePasswordForm = () => {
 
   return (
     <form onSubmit={handleSubmit(onSubmit)} className="mb-4 w-100 ">
-      <Input disabled={changePassword.isLoading} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder="Current password" />
-      <Input disabled={changePassword.isLoading} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder="New password" />
-      <Input disabled={changePassword.isLoading} {...register('newPasswordConfirm')} error={errors.newPasswordConfirm?.message} className="mt-2" type="password" placeholder="Confirm new password" />
+      <Input disabled={changePassword.isLoading} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder={t('form.current-password')} />
+      <Input disabled={changePassword.isLoading} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder={t('form.new-password')} />
+      <Input
+        disabled={changePassword.isLoading}
+        {...register('newPasswordConfirm')}
+        error={errors.newPasswordConfirm?.message}
+        className="mt-2"
+        type="password"
+        placeholder={t('form.confirm-password')}
+      />
       <Button disabled={changePassword.isLoading} className="mt-3" type="submit">
-        Change password
+        {t('form.change-password')}
       </Button>
     </form>
   );

+ 22 - 18
src/client/modules/Settings/components/OtpForm/OtpForm.tsx

@@ -8,8 +8,12 @@ import { QRCodeSVG } from 'qrcode.react';
 import { OtpInput } from '@/components/ui/OtpInput';
 import { toast } from 'react-hot-toast';
 import { useDisclosure } from '@/client/hooks/useDisclosure';
+import { useTranslations } from 'next-intl';
+import type { MessageKey } from '@/server/utils/errors';
 
 export const OtpForm = () => {
+  const globalT = useTranslations();
+  const t = useTranslations('settings.security');
   const [password, setPassword] = React.useState('');
   const [key, setKey] = React.useState('');
   const [uri, setUri] = React.useState('');
@@ -28,7 +32,7 @@ export const OtpForm = () => {
     },
     onError: (e) => {
       setPassword('');
-      toast.error(`Error getting TOTP URI: ${e.message}`);
+      toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
     onSuccess: (data) => {
       setKey(data.key);
@@ -40,13 +44,13 @@ export const OtpForm = () => {
     onMutate: () => {},
     onError: (e) => {
       setTotpCode('');
-      toast.error(`Error setting up TOTP: ${e.message}`);
+      toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
     onSuccess: () => {
       setTotpCode('');
       setKey('');
       setUri('');
-      toast.success('Two-factor authentication enabled');
+      toast.success(t('2fa-enable-success'));
       ctx.auth.me.invalidate();
     },
   });
@@ -57,10 +61,10 @@ export const OtpForm = () => {
     },
     onError: (e) => {
       setPassword('');
-      toast.error(`Error disabling TOTP: ${e.message}`);
+      toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
     onSuccess: () => {
-      toast.success('Two-factor authentication disabled');
+      toast.success(t('2fa-disable-success'));
       ctx.auth.me.invalidate();
     },
   });
@@ -71,18 +75,18 @@ export const OtpForm = () => {
     return (
       <div className="mt-4">
         <div className="mb-4">
-          <p className="text-muted">Scan this QR code with your authenticator app.</p>
+          <p className="text-muted">{t('scan-qr-code')}</p>
           <QRCodeSVG value={uri} />
         </div>
         <div className="mb-4">
-          <p className="text-muted">Or enter this key manually.</p>
+          <p className="text-muted">{t('enter-key-manually')}</p>
           <Input name="secret key" value={key} readOnly />
         </div>
         <div className="mb-4">
-          <p className="text-muted">Enter the code from your authenticator app.</p>
+          <p className="text-muted">{t('enter-2fa-code')}</p>
           <OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
           <Button disabled={totpCode.trim().length < 6} onClick={() => setupTotp.mutate({ totpCode })} className="mt-3 btn-success">
-            Enable 2FA
+            {t('enable-2fa')}
           </Button>
         </div>
       </div>
@@ -99,7 +103,7 @@ export const OtpForm = () => {
 
   return (
     <>
-      {!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totpEnabled} label="Enable two-factor authentication" />}
+      {!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totpEnabled} label={t('enable-2fa')} />}
       {getTotpUri.isLoading && (
         <div className="progress w-50">
           <div className="progress-bar progress-bar-indeterminate bg-green" />
@@ -109,7 +113,7 @@ export const OtpForm = () => {
       <Dialog open={setupOtpDisclosure.isOpen} onOpenChange={(o: boolean) => setupOtpDisclosure.toggle(o)}>
         <DialogContent size="sm">
           <DialogHeader>
-            <DialogTitle>Password needed</DialogTitle>
+            <DialogTitle>{t('password-needed')}</DialogTitle>
           </DialogHeader>
           <DialogDescription className="d-flex flex-column">
             <form
@@ -118,10 +122,10 @@ export const OtpForm = () => {
                 getTotpUri.mutate({ password });
               }}
             >
-              <p className="text-muted">Your password is required to setup two-factor authentication.</p>
-              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
+              <p className="text-muted">{t('password-needed-hint')}</p>
+              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} />
               <Button loading={getTotpUri.isLoading} type="submit" className="btn-success mt-3">
-                Enable 2FA
+                {t('enable-2fa')}
               </Button>
             </form>
           </DialogDescription>
@@ -130,7 +134,7 @@ export const OtpForm = () => {
       <Dialog open={disableOtpDisclosure.isOpen} onOpenChange={(o: boolean) => disableOtpDisclosure.toggle(o)}>
         <DialogContent size="sm">
           <DialogHeader>
-            <DialogTitle>Password needed</DialogTitle>
+            <DialogTitle>{t('password-needed')}</DialogTitle>
           </DialogHeader>
           <DialogDescription className="d-flex flex-column">
             <form
@@ -139,10 +143,10 @@ export const OtpForm = () => {
                 disableTotp.mutate({ password });
               }}
             >
-              <p className="text-muted">Your password is required to disable two-factor authentication.</p>
-              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
+              <p className="text-muted">{t('password-needed-hint')}</p>
+              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} />
               <Button loading={disableTotp.isLoading} type="submit" className="btn-danger mt-3">
-                Disable 2FA
+                {t('disable-2fa')}
               </Button>
             </form>
           </DialogDescription>

+ 32 - 32
src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx

@@ -1,5 +1,6 @@
 import { Button } from '@/components/ui/Button';
 import { Input } from '@/components/ui/Input';
+import { useTranslations } from 'next-intl';
 import React, { useEffect } from 'react';
 import { useForm } from 'react-hook-form';
 import validator from 'validator';
@@ -19,30 +20,31 @@ interface IProps {
   submitErrors?: Record<string, string>;
 }
 
-const validateFields = (values: SettingsFormValues) => {
-  const errors: { [K in keyof SettingsFormValues]?: string } = {};
+export const SettingsForm = (props: IProps) => {
+  const { onSubmit, initalValues, loading, submitErrors } = props;
+  const t = useTranslations('settings.settings');
 
-  if (values.dnsIp && !validator.isIP(values.dnsIp)) {
-    errors.dnsIp = 'Invalid IP address';
-  }
+  const validateFields = (values: SettingsFormValues) => {
+    const errors: { [K in keyof SettingsFormValues]?: string } = {};
 
-  if (values.internalIp && values.internalIp !== 'localhost' && !validator.isIP(values.internalIp)) {
-    errors.internalIp = 'Invalid IP address';
-  }
+    if (values.dnsIp && !validator.isIP(values.dnsIp)) {
+      errors.dnsIp = t('invalid-ip');
+    }
 
-  if (values.appsRepoUrl && !validator.isURL(values.appsRepoUrl)) {
-    errors.appsRepoUrl = 'Invalid URL';
-  }
+    if (values.internalIp && values.internalIp !== 'localhost' && !validator.isIP(values.internalIp)) {
+      errors.internalIp = t('invalid-ip');
+    }
 
-  if (values.domain && !validator.isFQDN(values.domain)) {
-    errors.domain = 'Invalid domain';
-  }
+    if (values.appsRepoUrl && !validator.isURL(values.appsRepoUrl)) {
+      errors.appsRepoUrl = t('invalid-url');
+    }
 
-  return errors;
-};
+    if (values.domain && !validator.isFQDN(values.domain)) {
+      errors.domain = t('invalid-domain');
+    }
 
-export const SettingsForm = (props: IProps) => {
-  const { onSubmit, initalValues, loading, submitErrors } = props;
+    return errors;
+  };
 
   const {
     register,
@@ -84,31 +86,29 @@ export const SettingsForm = (props: IProps) => {
 
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
-      <h2 className="text-2xl font-bold">General settings</h2>
-      <p className="mb-4">This will update your settings.json file. Make sure you know what you are doing before updating these values.</p>
+      <h2 className="text-2xl font-bold">{t('title')}</h2>
+      <p className="mb-4">{t('subtitle')}</p>
       <div className="mb-3">
-        <Input {...register('domain')} label="Domain name" error={errors.domain?.message} placeholder="tipi.localhost" />
-        <span className="text-muted">
-          Make sure this domain contains a <strong>A</strong> record pointing to your IP.
-        </span>
+        <Input {...register('domain')} label={t('domain-name')} error={errors.domain?.message} placeholder="tipi.localhost" />
+        <span className="text-muted">{t('domain-name-hint')}</span>
       </div>
       <div className="mb-3">
-        <Input {...register('dnsIp')} label="DNS IP" error={errors.dnsIp?.message} placeholder="9.9.9.9" />
+        <Input {...register('dnsIp')} label={t('dns-ip')} error={errors.dnsIp?.message} placeholder="9.9.9.9" />
       </div>
       <div className="mb-3">
-        <Input {...register('internalIp')} label="Internal IP" error={errors.internalIp?.message} placeholder="192.168.1.100" />
-        <span className="text-muted">IP address your server is listening on.</span>
+        <Input {...register('internalIp')} label={t('internal-ip')} error={errors.internalIp?.message} placeholder="192.168.1.100" />
+        <span className="text-muted">{t('internal-ip-hint')}</span>
       </div>
       <div className="mb-3">
-        <Input {...register('appsRepoUrl')} label="Apps repo URL" error={errors.appsRepoUrl?.message} placeholder="https://github.com/meienberger/runtipi-appstore" />
-        <span className="text-muted">URL to the apps repository.</span>
+        <Input {...register('appsRepoUrl')} label={t('apps-repo')} error={errors.appsRepoUrl?.message} placeholder="https://github.com/meienberger/runtipi-appstore" />
+        <span className="text-muted">{t('apps-repo-hint')}</span>
       </div>
       <div className="mb-3">
-        <Input {...register('storagePath')} label="Storage path" error={errors.storagePath?.message} placeholder="Storage path" />
-        <span className="text-muted">Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists</span>
+        <Input {...register('storagePath')} label={t('storage-path')} error={errors.storagePath?.message} placeholder="Storage path" />
+        <span className="text-muted">{t('storage-path-hint')}</span>
       </div>
       <Button loading={loading} type="submit" className="btn-success">
-        Save
+        {t('submit')}
       </Button>
     </form>
   );

+ 15 - 12
src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx

@@ -3,6 +3,8 @@ import semver from 'semver';
 import { toast } from 'react-hot-toast';
 import Markdown from '@/components/Markdown/Markdown';
 import { IconStar } from '@tabler/icons-react';
+import { useTranslations } from 'next-intl';
+import { MessageKey } from '@/server/utils/errors';
 import { Button } from '../../../../components/ui/Button';
 import { useDisclosure } from '../../../../hooks/useDisclosure';
 import { RestartModal } from '../../components/RestartModal';
@@ -11,6 +13,7 @@ import { trpc } from '../../../../utils/trpc';
 import { useSystemStore } from '../../../../state/systemStore';
 
 export const GeneralActions = () => {
+  const t = useTranslations();
   const versionQuery = trpc.system.getVersion.useQuery(undefined, { staleTime: 0 });
 
   const [loading, setLoading] = React.useState(false);
@@ -28,10 +31,10 @@ export const GeneralActions = () => {
     onSuccess: async () => {
       setPollStatus(true);
     },
-    onError: (error) => {
+    onError: (e) => {
       updateDisclosure.close();
       setLoading(false);
-      toast.error(`Error updating instance: ${error.message}`);
+      toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
   });
 
@@ -42,16 +45,16 @@ export const GeneralActions = () => {
     onSuccess: async () => {
       setPollStatus(true);
     },
-    onError: (error) => {
+    onError: (e) => {
       restartDisclosure.close();
       setLoading(false);
-      toast.error(`Error restarting instance: ${error.message}`);
+      toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
   });
 
   const renderUpdate = () => {
     if (isLatest) {
-      return <Button disabled>Already up to date</Button>;
+      return <Button disabled>{t('settings.actions.already-latest')}</Button>;
     }
 
     return (
@@ -69,7 +72,7 @@ export const GeneralActions = () => {
           </div>
         )}
         <Button onClick={updateDisclosure.open} className="mt-3 mr-2 btn-success">
-          Update to {versionQuery.data?.latest}
+          {t('settings.actions.update', { version: versionQuery.data?.latest })}
         </Button>
       </div>
     );
@@ -78,14 +81,14 @@ export const GeneralActions = () => {
   return (
     <>
       <div className="card-body">
-        <h2 className="mb-4">Actions</h2>
-        <h3 className="card-title mt-4">Current version: {versionQuery.data?.current}</h3>
-        <p className="card-subtitle">{isLatest ? 'Stay up to date with the latest version of Tipi' : `A new version (${versionQuery.data?.latest}) of Tipi is available`}</p>
+        <h2 className="mb-4">{t('settings.actions.title')}</h2>
+        <h3 className="card-title mt-4">{t('settings.actions.current-version', { version: versionQuery.data?.current })}</h3>
+        <p className="card-subtitle">{isLatest ? t('settings.actions.stay-up-to-date') : t('settings.actions.new-version', { version: versionQuery.data?.latest })}</p>
         {renderUpdate()}
-        <h3 className="card-title mt-4">Maintenance</h3>
-        <p className="card-subtitle">Common actions to perform on your instance</p>
+        <h3 className="card-title mt-4">{t('settings.actions.maintenance-title')}</h3>
+        <p className="card-subtitle">{t('settings.actions.maintenance-subtitle')}</p>
         <div>
-          <Button onClick={restartDisclosure.open}>Restart</Button>
+          <Button onClick={restartDisclosure.open}>{t('settings.actions.restart')}</Button>
         </div>
       </div>
       <RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={() => restart.mutate()} loading={loading} />

+ 7 - 5
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx

@@ -1,25 +1,27 @@
 import React from 'react';
 import { IconLock, IconKey } from '@tabler/icons-react';
+import { useTranslations } from 'next-intl';
 import { OtpForm } from '../../components/OtpForm';
 import { ChangePasswordForm } from '../../components/ChangePasswordForm';
 
 export const SecurityContainer = () => {
+  const t = useTranslations('settings.security');
   return (
     <div className="card-body">
       <div className="d-flex">
         <IconKey className="me-2" />
-        <h2>Change password</h2>
+        <h2>{t('change-password-title')}</h2>
       </div>
-      <p className="text-muted">Changing your password will log you out of all devices.</p>
+      <p className="text-muted">{t('change-password-subtitle')}</p>
       <ChangePasswordForm />
       <div className="d-flex">
         <IconLock className="me-2" />
-        <h2>Two-Factor Authentication</h2>
+        <h2>{t('2fa-title')}</h2>
       </div>
       <p className="text-muted">
-        Two-factor authentication (2FA) adds an additional layer of security to your account.
+        {t('2fa-subtitle')}
         <br />
-        When enabled, you will be prompted to enter a code from your authenticator app when you log in.
+        {t('2fa-subtitle-2')}
       </p>
       <OtpForm />
     </div>

+ 5 - 2
src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx

@@ -1,21 +1,24 @@
 import React, { useState } from 'react';
 import { trpc } from '@/utils/trpc';
 import { toast } from 'react-hot-toast';
+import { MessageKey } from '@/server/utils/errors';
+import { useTranslations } from 'next-intl';
 import { SettingsForm, SettingsFormValues } from '../../components/SettingsForm';
 
 export const SettingsContainer = () => {
+  const t = useTranslations();
   const [errors, setErrors] = useState<Record<string, string>>({});
   const getSettings = trpc.system.getSettings.useQuery();
   const updateSettings = trpc.system.updateSettings.useMutation({
     onSuccess: () => {
-      toast.success('Settings updated. Restart your instance to apply new settings.');
+      toast.success(t('settings.settings.settings-updated'));
     },
     onError: (e) => {
       if (e.shape?.data.zodError) {
         setErrors(e.shape.data.zodError);
       }
 
-      toast.error(`Error saving settings: ${e.message}`);
+      toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
     },
   });
 

+ 6 - 4
src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx

@@ -1,20 +1,22 @@
 import React from 'react';
 import type { NextPage } from 'next';
 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useTranslations } from 'next-intl';
 import { Layout } from '../../../../components/Layout';
 import { GeneralActions } from '../../containers/GeneralActions';
 import { SettingsContainer } from '../../containers/SettingsContainer';
 import { SecurityContainer } from '../../containers/SecurityContainer';
 
 export const SettingsPage: NextPage = () => {
+  const t = useTranslations('settings');
   return (
-    <Layout title="Settings">
+    <Layout title={t('title')}>
       <div className="card d-flex">
         <Tabs defaultValue="actions">
           <TabsList>
-            <TabsTrigger value="actions">Actions</TabsTrigger>
-            <TabsTrigger value="settings">Settings</TabsTrigger>
-            <TabsTrigger value="security">Security</TabsTrigger>
+            <TabsTrigger value="actions">{t('actions.tab-title')}</TabsTrigger>
+            <TabsTrigger value="settings">{t('settings.tab-title')}</TabsTrigger>
+            <TabsTrigger value="security">{t('security.tab-title')}</TabsTrigger>
           </TabsList>
           <TabsContent value="actions">
             <GeneralActions />

+ 4 - 0
src/client/state/uiStore.ts

@@ -10,12 +10,16 @@ type UIStore = {
   translator: typeof defaultTranslator;
   setMenuItem: (menuItem: string) => void;
   setDarkMode: (darkMode: boolean) => void;
+  setTranslator: (translator: typeof defaultTranslator) => void;
 };
 
 export const useUIStore = create<UIStore>((set) => ({
   menuItem: 'dashboard',
   darkMode: false,
   translator: defaultTranslator,
+  setTranslator: (translator: typeof defaultTranslator) => {
+    set({ translator });
+  },
   setDarkMode: (darkMode: boolean) => {
     if (darkMode) {
       localStorage.setItem('darkMode', darkMode.toString());

+ 0 - 5
src/client/utils/page-helpers.ts

@@ -2,8 +2,6 @@ import nookies from 'nookies';
 import { GetServerSideProps } from 'next';
 import merge from 'lodash.merge';
 import { getLocaleFromString } from '@/shared/internationalization/locales';
-import { createTranslator } from 'next-intl';
-import { useUIStore } from '../state/uiStore';
 
 export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
   const { userId } = ctx.req.session;
@@ -42,9 +40,6 @@ export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
   const messages = (await import(`../messages/${locale}.json`)).default;
   const mergedMessages = merge(englishMessages, messages);
 
-  const translator = createTranslator({ locale, messages: mergedMessages });
-  useUIStore.setState({ translator });
-
   return {
     props: {
       messages: mergedMessages,

+ 10 - 2
src/pages/_app.tsx

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
 import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
 import type { AppProps } from 'next/app';
 import Head from 'next/head';
-import { NextIntlProvider } from 'next-intl';
+import { NextIntlProvider, createTranslator } from 'next-intl';
 import '../client/styles/global.css';
 import '../client/styles/global.scss';
 import 'react-tooltip/dist/react-tooltip.css';
@@ -20,7 +20,7 @@ import { SystemStatus, useSystemStore } from '../client/state/systemStore';
  * @returns {JSX.Element} - JSX element
  */
 function MyApp({ Component, pageProps }: AppProps) {
-  const { setDarkMode } = useUIStore();
+  const { setDarkMode, setTranslator } = useUIStore();
   const { setStatus, setVersion, pollStatus } = useSystemStore();
   const { locale } = useLocale();
 
@@ -47,6 +47,14 @@ function MyApp({ Component, pageProps }: AppProps) {
     themeCheck();
   }, [setDarkMode]);
 
+  useEffect(() => {
+    const translator = createTranslator({
+      messages: pageProps.messages,
+      locale,
+    });
+    setTranslator(translator);
+  }, [pageProps.messages, locale, setTranslator]);
+
   return (
     <main className="h-100">
       <NextIntlProvider locale={locale} messages={pageProps.messages}>

+ 8 - 7
src/server/services/system/system.service.ts

@@ -1,6 +1,7 @@
 import semver from 'semver';
 import { z } from 'zod';
 import fetch from 'node-fetch-commonjs';
+import { TranslatedError } from '@/server/utils/errors';
 import { readJsonFile } from '../../common/fs.helpers';
 import { EventDispatcher } from '../../core/EventDispatcher';
 import { Logger } from '../../core/Logger';
@@ -80,23 +81,23 @@ export class SystemServiceClass {
     const { current, latest } = await this.getVersion();
 
     if (TipiConfig.getConfig().NODE_ENV === 'development') {
-      throw new Error('Cannot update in development mode');
+      throw new TranslatedError('server-messages.errors.not-allowed-in-dev');
     }
 
     if (!latest) {
-      throw new Error('Could not get latest version');
+      throw new TranslatedError('server-messages.errors.could-not-get-latest-version');
     }
 
     if (semver.gt(current, latest)) {
-      throw new Error('Current version is newer than latest version');
+      throw new TranslatedError('server-messages.errors.current-version-is-latest');
     }
 
     if (semver.eq(current, latest)) {
-      throw new Error('Current version is already up to date');
+      throw new TranslatedError('server-messages.errors.current-version-is-latest');
     }
 
     if (semver.major(current) !== semver.major(latest)) {
-      throw new Error('The major version has changed. Please update manually (instructions on GitHub)');
+      throw new TranslatedError('server-messages.errors.major-version-update');
     }
 
     TipiConfig.setConfig('status', 'UPDATING');
@@ -108,11 +109,11 @@ export class SystemServiceClass {
 
   public restart = async (): Promise<boolean> => {
     if (TipiConfig.getConfig().NODE_ENV === 'development') {
-      throw new Error('Cannot restart in development mode');
+      throw new TranslatedError('server-messages.errors.not-allowed-in-dev');
     }
 
     if (TipiConfig.getConfig().demoMode) {
-      throw new Error('Cannot restart in demo mode');
+      throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
     }
 
     TipiConfig.setConfig('status', 'RESTARTING');