feat: localize authentication flow

This commit is contained in:
Nicolas Meienberger 2023-05-06 16:03:07 +02:00
parent cc772c5061
commit f5a57d75b2
16 changed files with 304 additions and 141 deletions

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
import z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
@ -19,6 +20,7 @@ interface IProps {
}
export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
const t = useTranslations('auth');
const {
register,
handleSubmit,
@ -35,14 +37,23 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
return (
<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">
<Link href="/reset-password">Forgot password?</Link>
<Link href="/reset-password">{t('form.forgot')}</Link>
</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">
Login
{t('login.submit')}
</Button>
</form>
);

View file

@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
@ -12,23 +13,23 @@ interface IProps {
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 }) => {
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 {
register,
handleSubmit,
@ -39,20 +40,20 @@ export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
return (
<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
{...register('passwordConfirm')}
label="Confirm password"
label={t('form.password-confirmation')}
error={errors.passwordConfirm?.message}
disabled={loading}
type="password"
className="mb-3"
placeholder="Confirm your password"
placeholder={t('form.password-confirmation-placeholder')}
/>
<Button loading={loading} type="submit" className="btn btn-primary w-100">
Register
{t('register.submit')}
</Button>
</form>
);

View file

@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
@ -13,22 +14,23 @@ interface IProps {
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 }) => {
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 {
register,
handleSubmit,
@ -39,22 +41,30 @@ export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCance
return (
<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
{...register('passwordConfirm')}
label="Confirm password"
label={t('form.password-confirmation')}
error={errors.passwordConfirm?.message}
disabled={loading}
type="password"
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">
Reset password
{t('reset-password.submit')}
</Button>
<Button onClick={onCancel} type="button" className="btn btn-secondary w-100 mt-3">
Cancel password change request
{t('reset-password.cancel')}
</Button>
</form>
);

View file

@ -1,5 +1,6 @@
import { Button } from '@/components/ui/Button';
import { OtpInput } from '@/components/ui/OtpInput';
import { useTranslations } from 'next-intl';
import React from 'react';
type Props = {
@ -9,6 +10,7 @@ type Props = {
export const TotpForm = (props: Props) => {
const { onSubmit, loading } = props;
const t = useTranslations('auth');
const [totpCode, setTotpCode] = React.useState('');
return (
@ -20,11 +22,11 @@ export const TotpForm = (props: Props) => {
}}
>
<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)} />
<Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
Confirm
{t('totp.submit')}
</Button>
</div>
</form>

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import { useRouter } from 'next/router';
import React from 'react';
import { toast } from 'react-hot-toast';
import { useLocale } from '@/client/hooks/useLocale';
import { useTranslations } from 'next-intl';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { RegisterForm } from '../../components/RegisterForm';
@ -8,11 +10,17 @@ import { RegisterForm } from '../../components/RegisterForm';
type FormValues = { email: string; password: string };
export const RegisterContainer: React.FC = () => {
const t = useTranslations();
const { locale } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const register = trpc.auth.register.useMutation({
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: () => {
utils.auth.me.invalidate();
@ -21,7 +29,7 @@ export const RegisterContainer: React.FC = () => {
});
const handlerSubmit = (value: FormValues) => {
register.mutate({ username: value.email, password: value.password });
register.mutate({ username: value.email, password: value.password, locale });
};
return (

View file

@ -1,6 +1,7 @@
import { useRouter } from 'next/router';
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
@ -13,14 +14,19 @@ type Props = {
type FormValues = { password: string };
export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
const t = useTranslations();
const router = useRouter();
const utils = trpc.useContext();
const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
onSuccess: () => {
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({
@ -38,10 +44,10 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
if (resetPassword.isSuccess) {
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">
Back to login
{t('auth.reset-password.back-to-login')}
</Button>
</>
);
@ -53,8 +59,8 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
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>
<code>./scripts/reset-password.sh</code>
</pre>

View file

@ -1,5 +1,6 @@
import nookies from 'nookies';
import { GetServerSideProps } from 'next';
import merge from 'lodash.merge';
import { getLocaleFromString } from '@/shared/internationalization/locales';
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
@ -25,11 +26,22 @@ export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
const { locale: cookieLocale } = cookies;
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 {
props: {
messages: (await import(`../messages/${getLocaleFromString(locale)}.json`)).default,
messages: merge(englishMessages, messages),
},
};
};

View file

@ -48,13 +48,13 @@ describe('Login', () => {
});
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 () => {
const email = faker.internet.email();
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
@ -110,7 +110,7 @@ describe('Test: verifyTotp', () => {
await TipiCache.set(totpSessionId, user.id.toString());
// 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 () => {
@ -126,7 +126,7 @@ describe('Test: verifyTotp', () => {
await TipiCache.set(totpSessionId, user.id.toString());
// 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 () => {
@ -135,7 +135,7 @@ describe('Test: verifyTotp', () => {
await TipiCache.set(totpSessionId, '1234');
// 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 () => {
@ -151,7 +151,7 @@ describe('Test: verifyTotp', () => {
await TipiCache.set(totpSessionId, user.id.toString());
// 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);
});
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
const email = faker.internet.email();
const user = await createUser({ email, totpEnabled: true }, database);
// 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 () => {
@ -224,7 +224,7 @@ describe('Test: getTotpUri', () => {
const user = await createUser({ email }, database);
// 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 () => {
@ -232,7 +232,7 @@ describe('Test: getTotpUri', () => {
const userId = 11;
// 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 () => {
@ -242,7 +242,7 @@ describe('Test: getTotpUri', () => {
const user = await createUser({ email }, database);
// 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);
// 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 () => {
@ -282,7 +282,7 @@ describe('Test: setupTotp', () => {
const userId = 11;
// 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 () => {
@ -295,7 +295,7 @@ describe('Test: setupTotp', () => {
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
// 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 () => {
@ -305,7 +305,7 @@ describe('Test: setupTotp', () => {
const user = await createUser({ email }, database);
// 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);
// 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 () => {
@ -340,7 +340,7 @@ describe('Test: disableTotp', () => {
const userId = 11;
// 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 () => {
@ -349,7 +349,7 @@ describe('Test: disableTotp', () => {
const user = await createUser({ email, totpEnabled: true }, database);
// 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
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 () => {
@ -398,15 +396,15 @@ describe('Register', () => {
// Act & Assert
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 () => {
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 () => {
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 () => {
@ -423,7 +421,7 @@ describe('Register', () => {
});
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 () => {
@ -434,7 +432,7 @@ describe('Register', () => {
const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
// 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({});
// 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 () => {
@ -556,7 +554,7 @@ describe('Test: changeOperatorPassword', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// 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 () => {
@ -639,7 +637,7 @@ describe('Test: changePassword', () => {
const newPassword = faker.internet.password();
// 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 () => {
@ -649,7 +647,7 @@ describe('Test: changePassword', () => {
const newPassword = faker.internet.password();
// 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 () => {
@ -659,7 +657,7 @@ describe('Test: changePassword', () => {
const newPassword = faker.internet.password(7);
// 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 () => {
@ -670,7 +668,7 @@ describe('Test: changePassword', () => {
const newPassword = faker.internet.password();
// 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 () => {

View file

@ -5,11 +5,12 @@ import { generateSessionId } from '@/server/common/get-server-auth-session';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { Context } from '@/server/context';
import { TranslatedError } from '@/server/utils/errors';
import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
import { decrypt, encrypt } from '../../utils/encryption';
import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
type UsernamePasswordInput = {
username: string;
@ -29,20 +30,19 @@ export class AuthServiceClass {
*
* @param {UsernamePasswordInput} input - An object containing the user's username and password
* @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']) => {
const { password, username } = input;
const user = await this.queries.getUserByUsername(username);
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);
if (!isPasswordValid) {
throw new Error('Wrong password');
throw new TranslatedError('server-messages.errors.invalid-credentials');
}
if (user.totpEnabled) {
@ -64,31 +64,30 @@ export class AuthServiceClass {
* @param {string} params.totpSessionId - The TOTP session ID
* @param {string} params.totpCode - The TOTP code
* @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']) => {
const { totpSessionId, totpCode } = params;
const userId = await TipiCache.get(totpSessionId);
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));
if (!user) {
throw new Error('User not found');
throw new TranslatedError('server-messages.errors.user-not-found');
}
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 isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
throw new TranslatedError('server-messages.errors.totp-invalid-code');
}
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 {number} params.userId - The user's ID
* @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 }) => {
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;
@ -114,16 +112,16 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
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);
if (!isPasswordValid) {
throw new Error('Invalid password');
throw new TranslatedError('server-messages.errors.invalid-password');
}
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;
@ -144,25 +142,25 @@ export class AuthServiceClass {
public setupTotp = async (params: { userId: number; totpCode: string }) => {
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 user = await this.queries.getUserById(userId);
if (!user) {
throw new Error('User not found');
throw new TranslatedError('server-messages.errors.user-not-found');
}
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 isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
throw new TranslatedError('server-messages.errors.totp-invalid-code');
}
await this.queries.updateUser(userId, { totpEnabled: true });
@ -176,16 +174,16 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
if (!user) {
throw new Error('User not found');
throw new TranslatedError('server-messages.errors.user-not-found');
}
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);
if (!isPasswordValid) {
throw new Error('Invalid password');
throw new TranslatedError('server-messages.errors.invalid-password');
}
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 {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']) => {
const operators = await this.queries.getOperators();
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 email = username.trim().toLowerCase();
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)) {
throw new Error('Invalid username');
throw new TranslatedError('server-messages.errors.invalid-username');
}
const user = await this.queries.getUserByUsername(email);
if (user) {
throw new Error('User already exists');
throw new TranslatedError('server-messages.errors.user-already-exists');
}
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) });
if (!newUser) {
throw new Error('Error creating user');
throw new TranslatedError('server-messages.errors.error-creating-user');
}
req.session.userId = newUser.id;
@ -243,7 +239,6 @@ export class AuthServiceClass {
* Retrieves the user with the provided ID
*
* @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) => {
if (!userId) return null;
@ -287,12 +282,10 @@ export class AuthServiceClass {
*
* @param {object} params - An object containing 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 }) => {
if (!AuthServiceClass.checkPasswordChangeRequest()) {
throw new Error('No password change request found');
throw new TranslatedError('server-messages.errors.no-change-password-request');
}
const { newPassword } = params;
@ -300,7 +293,7 @@ export class AuthServiceClass {
const user = await this.queries.getFirstOperator();
if (!user) {
throw new Error('Operator user not found');
throw new TranslatedError('server-messages.errors.operator-not-found');
}
const hash = await argon2.hash(newPassword);
@ -359,7 +352,7 @@ export class AuthServiceClass {
public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => {
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;
@ -367,17 +360,17 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
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);
if (!valid) {
throw new Error('Current password is invalid');
throw new TranslatedError('server-messages.errors.invalid-password');
}
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);
@ -400,13 +393,13 @@ export class AuthServiceClass {
const isLocaleValid = Locales.includes(locale);
if (!isLocaleValid) {
throw new Error('Invalid locale');
throw new TranslatedError('server-messages.errors.invalid-locale');
}
const user = await this.queries.getUserById(userId);
if (!user) {
throw new Error('User not found');
throw new TranslatedError('server-messages.errors.user-not-found');
}
await this.queries.updateUser(user.id, { locale });

View file

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

View file

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

View file

@ -1,13 +1,17 @@
import React, { FC, ReactElement } from 'react';
import { render, RenderOptions, renderHook } from '@testing-library/react';
import { Toaster } from 'react-hot-toast';
import { NextIntlProvider } from 'next-intl';
import { TRPCTestClientProvider } from './TRPCTestClientProvider';
import messages from '../src/client/messages/en.json';
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 });