feat: translate settings page

This commit is contained in:
Nicolas Meienberger 2023-05-15 08:05:41 +02:00 committed by Nicolas Meienberger
parent 8ef069114d
commit 148391b9c0
11 changed files with 148 additions and 115 deletions

View file

@ -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';
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'],
});
}
});
type FormValues = z.infer<typeof schema>;
import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors';
export const ChangePasswordForm = () => {
const globalT = useTranslations();
const t = useTranslations('settings.security');
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>;
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>
);

View file

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

View file

@ -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 } = {};
if (values.dnsIp && !validator.isIP(values.dnsIp)) {
errors.dnsIp = 'Invalid IP address';
}
if (values.internalIp && values.internalIp !== 'localhost' && !validator.isIP(values.internalIp)) {
errors.internalIp = 'Invalid IP address';
}
if (values.appsRepoUrl && !validator.isURL(values.appsRepoUrl)) {
errors.appsRepoUrl = 'Invalid URL';
}
if (values.domain && !validator.isFQDN(values.domain)) {
errors.domain = 'Invalid domain';
}
return errors;
};
export const SettingsForm = (props: IProps) => {
const { onSubmit, initalValues, loading, submitErrors } = props;
const t = useTranslations('settings.settings');
const validateFields = (values: SettingsFormValues) => {
const errors: { [K in keyof SettingsFormValues]?: string } = {};
if (values.dnsIp && !validator.isIP(values.dnsIp)) {
errors.dnsIp = t('invalid-ip');
}
if (values.internalIp && values.internalIp !== 'localhost' && !validator.isIP(values.internalIp)) {
errors.internalIp = t('invalid-ip');
}
if (values.appsRepoUrl && !validator.isURL(values.appsRepoUrl)) {
errors.appsRepoUrl = t('invalid-url');
}
if (values.domain && !validator.isFQDN(values.domain)) {
errors.domain = t('invalid-domain');
}
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>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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