feat: localize authentication flow
This commit is contained in:
parent
cc772c5061
commit
f5a57d75b2
16 changed files with 304 additions and 141 deletions
91
src/client/messages/en.json
Normal file
91
src/client/messages/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -59,6 +59,7 @@ export const handlers = [
|
|||
totpEnabled: false,
|
||||
id: faker.datatype.number(),
|
||||
username: faker.internet.userName(),
|
||||
locale: 'en',
|
||||
},
|
||||
}),
|
||||
getTRPCMock({
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
13
src/server/utils/errors.ts
Normal file
13
src/server/utils/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
|
|
Loading…
Add table
Reference in a new issue