diff --git a/chili-and-cilantro-api/src/controllers/api/game.ts b/chili-and-cilantro-api/src/controllers/api/game.ts index 3adb2e5..54464db 100644 --- a/chili-and-cilantro-api/src/controllers/api/game.ts +++ b/chili-and-cilantro-api/src/controllers/api/game.ts @@ -77,7 +77,10 @@ export class GameController extends BaseController { .isString() .trim() .notEmpty() - .matches(constants.USERNAME_REGEX, constants.USERNAME_REGEX_ERROR), + .matches( + constants.USERNAME_REGEX, + translate(StringNames.Validation_UsernameRegexErrorTemplate), + ), body('displayname') .isString() .trim() @@ -90,7 +93,10 @@ export class GameController extends BaseController { .optional() .isString() .trim() - .matches(constants.PASSWORD_REGEX, constants.PASSWORD_REGEX_ERROR), + .matches( + constants.PASSWORD_REGEX, + translate(StringNames.Validation_PasswordRegexErrorTemplate), + ), body('maxChefs').isInt({ min: 2, max: 8 }), ], }), @@ -104,12 +110,18 @@ export class GameController extends BaseController { .isString() .trim() .notEmpty() - .matches(constants.USERNAME_REGEX, constants.USERNAME_REGEX_ERROR), + .matches( + constants.USERNAME_REGEX, + translate(StringNames.Validation_UsernameRegexErrorTemplate), + ), body('password') .optional() .isString() .trim() - .matches(constants.PASSWORD_REGEX, constants.PASSWORD_REGEX_ERROR), + .matches( + constants.PASSWORD_REGEX, + translate(StringNames.Validation_PasswordRegexErrorTemplate), + ), body('displayname') .isString() .trim() diff --git a/chili-and-cilantro-api/src/controllers/api/user.ts b/chili-and-cilantro-api/src/controllers/api/user.ts index ab23ab6..91f3b0e 100644 --- a/chili-and-cilantro-api/src/controllers/api/user.ts +++ b/chili-and-cilantro-api/src/controllers/api/user.ts @@ -24,6 +24,7 @@ import { import { MailService } from '@sendgrid/mail'; import { NextFunction, Request, Response } from 'express'; import { body, query } from 'express-validator'; +import moment from 'moment-timezone'; import { findAuthToken } from '../../middlewares/authenticate-token'; import { JwtService } from '../../services/jwt'; import { RequestUserService } from '../../services/request-user'; @@ -58,7 +59,9 @@ export class UserController extends BaseController { .withMessage('Current password is required'), body('newPassword') .matches(constants.PASSWORD_REGEX) - .withMessage(constants.PASSWORD_REGEX_ERROR), + .withMessage( + translate(StringNames.Validation_PasswordRegexErrorTemplate), + ), ], }), routeConfig({ @@ -68,15 +71,24 @@ export class UserController extends BaseController { validation: [ body('username') .matches(constants.USERNAME_REGEX) - .withMessage(constants.USERNAME_REGEX_ERROR), + .withMessage( + translate(StringNames.Validation_UsernameRegexErrorTemplate), + ), body('displayname') .matches(constants.USER_DISPLAY_NAME_REGEX) .withMessage(constants.USER_DISPLAY_NAME_REGEX_ERROR), - body('email').isEmail().withMessage('Invalid email address'), + body('email') + .isEmail() + .withMessage(translate(StringNames.Validation_InvalidEmail)), body('password') .matches(constants.PASSWORD_REGEX) - .withMessage(constants.PASSWORD_REGEX_ERROR), - body('timezone').optional().isString(), + .withMessage( + translate(StringNames.Validation_PasswordRegexErrorTemplate), + ), + body('timezone') + .optional() + .isIn(moment.tz.names()) + .withMessage(translate(StringNames.Validation_InvalidTimezone)), ], useAuthentication: false, }), @@ -87,21 +99,27 @@ export class UserController extends BaseController { validation: [ body().custom((value, { req }) => { if (!req.body.username && !req.body.email) { - throw new Error('Either username or email is required'); + throw new Error( + translate(StringNames.Login_UsernameOrEmailRequired), + ); } return true; }), body('username') .optional() .matches(constants.USERNAME_REGEX) - .withMessage(constants.USERNAME_REGEX_ERROR), + .withMessage( + translate(StringNames.Validation_UsernameRegexErrorTemplate), + ), body('email') .optional() .isEmail() - .withMessage('Invalid email address'), + .withMessage(translate(StringNames.Validation_InvalidEmail)), body('password') .matches(constants.PASSWORD_REGEX) - .withMessage(constants.PASSWORD_REGEX_ERROR), + .withMessage( + translate(StringNames.Validation_PasswordRegexErrorTemplate), + ), ], useAuthentication: false, }), @@ -116,13 +134,16 @@ export class UserController extends BaseController { path: '/verify-email', handler: this.verifyEmailToken, validation: [ - query('token').not().isEmpty().withMessage('Token is required'), + query('token') + .not() + .isEmpty() + .withMessage(translate(StringNames.Validation_Required)), query('token') .isLength({ min: constants.EMAIL_TOKEN_LENGTH * 2, max: constants.EMAIL_TOKEN_LENGTH * 2, }) - .withMessage('Invalid token'), + .withMessage(translate(StringNames.Validation_InvalidToken)), ], useAuthentication: false, }), @@ -139,7 +160,9 @@ export class UserController extends BaseController { validation: [ body().custom((value, { req }) => { if (!req.body.username && !req.body.email) { - throw new Error('Either username or email is required'); + throw new Error( + translate(StringNames.Login_UsernameOrEmailRequired), + ); } return true; }), @@ -156,7 +179,7 @@ export class UserController extends BaseController { body('email') .isEmail() .normalizeEmail() - .withMessage('Invalid email address'), + .withMessage(translate(StringNames.Validation_InvalidEmail)), ], useAuthentication: false, }), @@ -165,13 +188,16 @@ export class UserController extends BaseController { path: '/verify-reset-token', handler: this.verifyResetToken, validation: [ - query('token').not().isEmpty().withMessage('Token is required'), + query('token') + .not() + .isEmpty() + .withMessage(translate(StringNames.Validation_Required)), query('token') .isLength({ min: constants.EMAIL_TOKEN_LENGTH * 2, max: constants.EMAIL_TOKEN_LENGTH * 2, }) - .withMessage('Invalid token'), + .withMessage(translate(StringNames.Validation_InvalidToken)), ], useAuthentication: false, }), @@ -183,7 +209,9 @@ export class UserController extends BaseController { body('token').notEmpty(), body('password') .matches(constants.PASSWORD_REGEX) - .withMessage(constants.PASSWORD_REGEX_ERROR), + .withMessage( + translate(StringNames.Validation_PasswordRegexErrorTemplate), + ), ], useAuthentication: false, }), diff --git a/chili-and-cilantro-api/src/services/chef.ts b/chili-and-cilantro-api/src/services/chef.ts index a5a77ba..b057a0a 100644 --- a/chili-and-cilantro-api/src/services/chef.ts +++ b/chili-and-cilantro-api/src/services/chef.ts @@ -16,7 +16,7 @@ export class ChefService extends BaseService { * Creates a new chef in the database * @param game The game the chef is joining * @param user The user joining the game - * @param userName The display name of the chef + * @param displayName The display name of the chef * @param host Whether the chef is the host of the game * @param chefId The id of the chef to create. If not provided, a new id will be generated * @returns A new chef document @@ -24,7 +24,7 @@ export class ChefService extends BaseService { public async newChefAsync( game: IGameDocument, user: IUserDocument, - userName: string, + displayName: string, host: boolean, chefId?: DefaultIdType, ): Promise { @@ -33,7 +33,7 @@ export class ChefService extends BaseService { { _id: chefId ?? new Types.ObjectId(), gameId: game._id, - name: userName, + name: displayName, userId: user._id, hand: UtilityService.makeHand(), placedCards: [], diff --git a/chili-and-cilantro-api/src/services/user.ts b/chili-and-cilantro-api/src/services/user.ts index 4cf9a6f..791a55b 100644 --- a/chili-and-cilantro-api/src/services/user.ts +++ b/chili-and-cilantro-api/src/services/user.ts @@ -1,7 +1,6 @@ import { AccountDeletedError, AccountLockedError, - AccountStatusError, AccountStatusTypeEnum, constants, DefaultIdType, @@ -11,6 +10,7 @@ import { EmailTokenType, EmailTokenUsedOrInvalidError, EmailVerifiedError, + HandleableError, ICreateUserBasics, IEmailTokenDocument, InvalidCredentialsError, @@ -24,6 +24,8 @@ import { ModelName, PendingEmailVerificationError, StringLanguages, + StringNames, + translate, UsernameInUseError, UsernameOrEmailRequiredError, UserNotFoundError, @@ -185,9 +187,11 @@ export class UserService extends BaseService { case AccountStatusTypeEnum.AdminDelete: case AccountStatusTypeEnum.SelfDelete: case AccountStatusTypeEnum.SelfDeleteWaitPeriod: - throw new AccountDeletedError(); + throw new AccountDeletedError(userDoc.accountStatusType); default: - throw new AccountStatusError(userDoc.accountStatusType); + throw new HandleableError( + translate(StringNames.Common_UnexpectedError), + ); } return userDoc; diff --git a/chili-and-cilantro-lib/.eslintrc.json b/chili-and-cilantro-lib/.eslintrc.json index 67352d2..24f1201 100644 --- a/chili-and-cilantro-lib/.eslintrc.json +++ b/chili-and-cilantro-lib/.eslintrc.json @@ -10,7 +10,11 @@ "files": ["*.ts", "*.tsx"], "rules": {}, "parserOptions": { - "project": ["chili-and-cilantro-lib/tsconfig.json"] + "project": [ + "chili-and-cilantro-lib/tsconfig.json", + "chili-and-cilantro-lib/tsconfig.spec.json", + "chili-and-cilantro-lib/tsconfig.lib.json" + ] } }, { diff --git a/chili-and-cilantro-lib/src/lib/__snapshots__/i18n.spec.ts.snap b/chili-and-cilantro-lib/src/lib/__snapshots__/i18n.spec.ts.snap index f11846d..7de941f 100644 --- a/chili-and-cilantro-lib/src/lib/__snapshots__/i18n.spec.ts.snap +++ b/chili-and-cilantro-lib/src/lib/__snapshots__/i18n.spec.ts.snap @@ -14,6 +14,7 @@ exports[`buildNestedI18n should handle English (UK) language 1`] = ` "confirmNewPassword": "Confirm New Password", "currentPassword": "Current Password", "dashboard": "Dashboard", + "email": "Email", "goToSplash": "Return to Welcome Screen", "loading": "Loading...", "logo": "logo", @@ -32,6 +33,11 @@ exports[`buildNestedI18n should handle English (UK) language 1`] = ` "title": "Your Dashboard", }, "forgotPassword": { + "forgotPassword": "Forgot Password", + "invalidToken": "Invalid or expired token. Please request a new password reset.", + "resetPassword": "Reset Password", + "sendResetToken": "Send Reset Email", + "success": "Your password has been successfully reset. You can now log in with your new password.", "title": "Forgot Password", }, "game": { @@ -72,8 +78,9 @@ exports[`buildNestedI18n should handle English (UK) language 1`] = ` "invalidToken": "Invalid Token", "newPasswordRequired": "New password is required", "passwordMatch": "Password and confirm must match", - "passwordRegexError": "Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Password must be at least {MIN_PASSWORD_LENGTH} characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "New password must be different than the current password", + "required": "Required", }, "validationError": "Validation Error", } @@ -93,6 +100,7 @@ exports[`buildNestedI18n should handle English (US) language 1`] = ` "confirmNewPassword": "Confirm New Password", "currentPassword": "Current Password", "dashboard": "Dashboard", + "email": "Email", "goToSplash": "Return to Welcome Screen", "loading": "Loading...", "logo": "logo", @@ -111,6 +119,11 @@ exports[`buildNestedI18n should handle English (US) language 1`] = ` "title": "Your Dashboard", }, "forgotPassword": { + "forgotPassword": "Forgot Password", + "invalidToken": "Invalid or expired token. Please request a new password reset.", + "resetPassword": "Reset Password", + "sendResetToken": "Send Reset Email", + "success": "Your password has been successfully reset. You can now log in with your new password.", "title": "Forgot Password", }, "game": { @@ -151,8 +164,9 @@ exports[`buildNestedI18n should handle English (US) language 1`] = ` "invalidToken": "Invalid Token", "newPasswordRequired": "New password is required", "passwordMatch": "Password and confirm must match", - "passwordRegexError": "Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "New password must be different than the current password", + "required": "Required", }, "validationError": "Validation Error", } @@ -172,6 +186,7 @@ exports[`buildNestedI18n should handle Español language 1`] = ` "confirmNewPassword": "Confirmar nueva contraseña", "currentPassword": "Contraseña actual", "dashboard": "Tablero", + "email": "Correo electrónica", "goToSplash": "Volver a la pantalla de bienvenida", "loading": "Cargando...", "logo": "logo", @@ -190,6 +205,11 @@ exports[`buildNestedI18n should handle Español language 1`] = ` "title": "Tu Tablero", }, "forgotPassword": { + "forgotPassword": "Olvidé mi contraseña", + "invalidToken": "Token inválido o expirado. Por favor, solicita un nuevo restablecimiento de contraseña.", + "resetPassword": "Restablecer contraseña", + "sendResetToken": "Enviar restablecimiento de contraseña", + "success": "Tu contraseña ha sido restablecida con éxito. Ahora puedes usar tu nueva contraseña para ingresar.", "title": "Contraseña olvidada", }, "game": { @@ -230,8 +250,9 @@ exports[`buildNestedI18n should handle Español language 1`] = ` "invalidToken": "Token inválido", "newPasswordRequired": "Se requiere la nueva contraseña", "passwordMatch": "La contraseña y la confirmación deben coincidir", - "passwordRegexError": "La contraseña debe tener al menos 8 caracteres y incluir al menos una letra, un número y un carácter especial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "La contraseña debe tener al menos 8 caracteres y incluir al menos una letra, un número y un carácter especial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "La nueva contraseña debe ser diferente a la contraseña actual", + "required": "Requerido", }, "validationError": "Error de validación", } @@ -251,6 +272,7 @@ exports[`buildNestedI18n should handle Français language 1`] = ` "confirmNewPassword": "Confirmer le nouveau mot de passe", "currentPassword": "Mot de passe actuel", "dashboard": "Tableau de bord", + "email": "Courriel", "goToSplash": "Retourner aucran d'accueil", "loading": "Chargement...", "logo": "logo", @@ -269,6 +291,11 @@ exports[`buildNestedI18n should handle Français language 1`] = ` "title": "Votre Tableau de Bord", }, "forgotPassword": { + "forgotPassword": "Mot de passe oublié", + "invalidToken": "Jeton invalide ou expiré. Veuillez demander un nouveau mot de passe.", + "resetPassword": "Réinitialiser le mot de passe", + "sendResetToken": "Envoyer le mot de passe par courriel", + "success": "Votre mot de passe a bien été changé. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.", "title": "Mot de passe oublié", }, "game": { @@ -309,8 +336,9 @@ exports[`buildNestedI18n should handle Français language 1`] = ` "invalidToken": "Jeton invalide", "newPasswordRequired": "Le nouveau mot de passe est requis", "passwordMatch": "Le mot de passe et la confirmation doivent correspondre", - "passwordRegexError": "Le mot de passe doit comporter au moins 8 caractères et inclure au moins une lettre, un chiffre et un caractère spécial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Le mot de passe doit comporter au moins 8 caractères et inclure au moins une lettre, un chiffre et un caractère spécial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "Le nouveau mot de passe doit être différent du mot de passe actuel", + "required": "Champ obligatoire", }, "validationError": "Erreur de validation", } @@ -330,6 +358,7 @@ exports[`buildNestedI18n should handle Український language 1`] = ` "confirmNewPassword": "Підтвердити новий пароль", "currentPassword": "Поточний пароль", "dashboard": "Панель", + "email": "Електронна пошта", "goToSplash": "Повернутися до екрану привітання", "loading": "Завантаження...", "logo": "лого", @@ -348,6 +377,11 @@ exports[`buildNestedI18n should handle Український language 1`] = ` "title": "Ваша Панель", }, "forgotPassword": { + "forgotPassword": "Забули пароль?", + "invalidToken": "Недіїдженний токен. Будь ласка, зверніться до підтримки.", + "resetPassword": "Скинути пароль", + "sendResetToken": "Відправити лист зі скиданням пароля", + "success": "Ваш пароль успішно змінено. Тепер ви можете використовувати новий пароль для входу.", "title": "Забули пароль", }, "game": { @@ -388,8 +422,9 @@ exports[`buildNestedI18n should handle Український language 1`] = ` "invalidToken": "Недійсний токен", "newPasswordRequired": "Новий пароль є обов’язковим", "passwordMatch": "Пароль та підтвердження повинні співпадати", - "passwordRegexError": "Пароль повинен містити принаймні 8 символів, включаючи принаймні одну літеру, одну цифру та один спеціальний символ (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Пароль повинен містити принаймні 8 символів, включаючи принаймні одну літеру, одну цифру та один спеціальний символ (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "Новий пароль повинен відрізнятися від поточного", + "required": "Обов’язково", }, "validationError": "Помилка валідації", } @@ -409,6 +444,7 @@ exports[`buildNestedI18n should handle 中文 language 1`] = ` "confirmNewPassword": "确认新密码", "currentPassword": "当前密码", "dashboard": "仪表板", + "email": "电子邮件", "goToSplash": "返回欢迎屏幕", "loading": "加载中...", "logo": "标志", @@ -427,6 +463,11 @@ exports[`buildNestedI18n should handle 中文 language 1`] = ` "title": "您的仪表板", }, "forgotPassword": { + "forgotPassword": "忘记密码", + "invalidToken": "无效或过期的令牌。请请求新密码重置。", + "resetPassword": "重置密码", + "sendResetToken": "发送重置电子邮件", + "success": "您的密码已成功重置。您现在可以使用新密码登录。", "title": "忘记密码", }, "game": { @@ -467,8 +508,9 @@ exports[`buildNestedI18n should handle 中文 language 1`] = ` "invalidToken": "无效令牌", "newPasswordRequired": "新密码是必需的", "passwordMatch": "密码和确认必须匹配", - "passwordRegexError": "密码必须至少8个字符长且包含至少一个字母、一个数字和一个特殊字符(!@#$%^&*()_+-=[]{};:"|,.<>/?)", + "passwordRegexErrorTemplate": "密码必须至少8个字符长且包含至少一个字母、一个数字和一个特殊字符(!@#$%^&*()_+-=[]{};:"|,.<>/?)", "passwordsDifferent": "新密码必须不同于当前密码", + "required": "必填", }, "validationError": "验证错误", } @@ -488,6 +530,7 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "confirmNewPassword": "Confirm New Password", "currentPassword": "Current Password", "dashboard": "Dashboard", + "email": "Email", "goToSplash": "Return to Welcome Screen", "loading": "Loading...", "logo": "logo", @@ -506,6 +549,11 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "title": "Your Dashboard", }, "forgotPassword": { + "forgotPassword": "Forgot Password", + "invalidToken": "Invalid or expired token. Please request a new password reset.", + "resetPassword": "Reset Password", + "sendResetToken": "Send Reset Email", + "success": "Your password has been successfully reset. You can now log in with your new password.", "title": "Forgot Password", }, "game": { @@ -546,8 +594,9 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "invalidToken": "Invalid Token", "newPasswordRequired": "New password is required", "passwordMatch": "Password and confirm must match", - "passwordRegexError": "Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "New password must be different than the current password", + "required": "Required", }, "validationError": "Validation Error", } @@ -567,6 +616,7 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "confirmNewPassword": "Confirm New Password", "currentPassword": "Current Password", "dashboard": "Dashboard", + "email": "Email", "goToSplash": "Return to Welcome Screen", "loading": "Loading...", "logo": "logo", @@ -585,6 +635,11 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "title": "Your Dashboard", }, "forgotPassword": { + "forgotPassword": "Forgot Password", + "invalidToken": "Invalid or expired token. Please request a new password reset.", + "resetPassword": "Reset Password", + "sendResetToken": "Send Reset Email", + "success": "Your password has been successfully reset. You can now log in with your new password.", "title": "Forgot Password", }, "game": { @@ -625,8 +680,9 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "invalidToken": "Invalid Token", "newPasswordRequired": "New password is required", "passwordMatch": "Password and confirm must match", - "passwordRegexError": "Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Password must be at least {MIN_PASSWORD_LENGTH} characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "New password must be different than the current password", + "required": "Required", }, "validationError": "Validation Error", } @@ -646,6 +702,7 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "confirmNewPassword": "Confirmer le nouveau mot de passe", "currentPassword": "Mot de passe actuel", "dashboard": "Tableau de bord", + "email": "Courriel", "goToSplash": "Retourner aucran d'accueil", "loading": "Chargement...", "logo": "logo", @@ -664,6 +721,11 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "title": "Votre Tableau de Bord", }, "forgotPassword": { + "forgotPassword": "Mot de passe oublié", + "invalidToken": "Jeton invalide ou expiré. Veuillez demander un nouveau mot de passe.", + "resetPassword": "Réinitialiser le mot de passe", + "sendResetToken": "Envoyer le mot de passe par courriel", + "success": "Votre mot de passe a bien été changé. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.", "title": "Mot de passe oublié", }, "game": { @@ -704,8 +766,9 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "invalidToken": "Jeton invalide", "newPasswordRequired": "Le nouveau mot de passe est requis", "passwordMatch": "Le mot de passe et la confirmation doivent correspondre", - "passwordRegexError": "Le mot de passe doit comporter au moins 8 caractères et inclure au moins une lettre, un chiffre et un caractère spécial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Le mot de passe doit comporter au moins 8 caractères et inclure au moins une lettre, un chiffre et un caractère spécial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "Le nouveau mot de passe doit être différent du mot de passe actuel", + "required": "Champ obligatoire", }, "validationError": "Erreur de validation", } @@ -725,6 +788,7 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "confirmNewPassword": "确认新密码", "currentPassword": "当前密码", "dashboard": "仪表板", + "email": "电子邮件", "goToSplash": "返回欢迎屏幕", "loading": "加载中...", "logo": "标志", @@ -743,6 +807,11 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "title": "您的仪表板", }, "forgotPassword": { + "forgotPassword": "忘记密码", + "invalidToken": "无效或过期的令牌。请请求新密码重置。", + "resetPassword": "重置密码", + "sendResetToken": "发送重置电子邮件", + "success": "您的密码已成功重置。您现在可以使用新密码登录。", "title": "忘记密码", }, "game": { @@ -783,8 +852,9 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "invalidToken": "无效令牌", "newPasswordRequired": "新密码是必需的", "passwordMatch": "密码和确认必须匹配", - "passwordRegexError": "密码必须至少8个字符长且包含至少一个字母、一个数字和一个特殊字符(!@#$%^&*()_+-=[]{};:"|,.<>/?)", + "passwordRegexErrorTemplate": "密码必须至少8个字符长且包含至少一个字母、一个数字和一个特殊字符(!@#$%^&*()_+-=[]{};:"|,.<>/?)", "passwordsDifferent": "新密码必须不同于当前密码", + "required": "必填", }, "validationError": "验证错误", } @@ -804,6 +874,7 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "confirmNewPassword": "Confirmar nueva contraseña", "currentPassword": "Contraseña actual", "dashboard": "Tablero", + "email": "Correo electrónica", "goToSplash": "Volver a la pantalla de bienvenida", "loading": "Cargando...", "logo": "logo", @@ -822,6 +893,11 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "title": "Tu Tablero", }, "forgotPassword": { + "forgotPassword": "Olvidé mi contraseña", + "invalidToken": "Token inválido o expirado. Por favor, solicita un nuevo restablecimiento de contraseña.", + "resetPassword": "Restablecer contraseña", + "sendResetToken": "Enviar restablecimiento de contraseña", + "success": "Tu contraseña ha sido restablecida con éxito. Ahora puedes usar tu nueva contraseña para ingresar.", "title": "Contraseña olvidada", }, "game": { @@ -862,8 +938,9 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "invalidToken": "Token inválido", "newPasswordRequired": "Se requiere la nueva contraseña", "passwordMatch": "La contraseña y la confirmación deben coincidir", - "passwordRegexError": "La contraseña debe tener al menos 8 caracteres y incluir al menos una letra, un número y un carácter especial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "La contraseña debe tener al menos 8 caracteres y incluir al menos una letra, un número y un carácter especial (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "La nueva contraseña debe ser diferente a la contraseña actual", + "required": "Requerido", }, "validationError": "Error de validación", } @@ -883,6 +960,7 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "confirmNewPassword": "Підтвердити новий пароль", "currentPassword": "Поточний пароль", "dashboard": "Панель", + "email": "Електронна пошта", "goToSplash": "Повернутися до екрану привітання", "loading": "Завантаження...", "logo": "лого", @@ -901,6 +979,11 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "title": "Ваша Панель", }, "forgotPassword": { + "forgotPassword": "Забули пароль?", + "invalidToken": "Недіїдженний токен. Будь ласка, зверніться до підтримки.", + "resetPassword": "Скинути пароль", + "sendResetToken": "Відправити лист зі скиданням пароля", + "success": "Ваш пароль успішно змінено. Тепер ви можете використовувати новий пароль для входу.", "title": "Забули пароль", }, "game": { @@ -941,8 +1024,9 @@ exports[`buildNestedI18nForLanguage should call buildNestedI18n with the correct "invalidToken": "Недійсний токен", "newPasswordRequired": "Новий пароль є обов’язковим", "passwordMatch": "Пароль та підтвердження повинні співпадати", - "passwordRegexError": "Пароль повинен містити принаймні 8 символів, включаючи принаймні одну літеру, одну цифру та один спеціальний символ (!@#$%^&*()_+-=[]{};':"|,.<>/?)", + "passwordRegexErrorTemplate": "Пароль повинен містити принаймні 8 символів, включаючи принаймні одну літеру, одну цифру та один спеціальний символ (!@#$%^&*()_+-=[]{};':"|,.<>/?)", "passwordsDifferent": "Новий пароль повинен відрізнятися від поточного", + "required": "Обов’язково", }, "validationError": "Помилка валідації", } diff --git a/chili-and-cilantro-lib/src/lib/constants.ts b/chili-and-cilantro-lib/src/lib/constants.ts index 1bf0434..4abce43 100644 --- a/chili-and-cilantro-lib/src/lib/constants.ts +++ b/chili-and-cilantro-lib/src/lib/constants.ts @@ -205,20 +205,6 @@ export const USERNAME_REGEX = createUsernameRegex( MAX_USERNAME_LENGTH, ); -const createUsernameRegexError = (minLength: number, maxLength: number) => { - return `Username must be between ${minLength} and ${maxLength} characters, and: - • Start with a letter, number, or Unicode character - • Can contain letters, numbers, underscores, hyphens, and Unicode characters - • Cannot contain spaces or special characters other than underscores and hyphens`; -}; -/** - * The error message for invalid usernames. - */ -export const USERNAME_REGEX_ERROR = createUsernameRegexError( - MIN_USERNAME_LENGTH, - MAX_USERNAME_LENGTH, -); - const createPasswordRegex = (minLength: number, maxLength: number) => { return new RegExp( `^(?=.*\\p{Ll})(?=.*\\p{Lu})(?=.*\\p{Nd})(?=.*[\\p{P}\\p{S}])[\\p{L}\\p{M}\\p{Nd}\\p{P}\\p{S}]{${minLength},${maxLength}}$`, @@ -233,21 +219,6 @@ export const PASSWORD_REGEX = createPasswordRegex( MAX_PASSWORD_LENGTH, ); -const createPasswordRegexError = (minLength: number, maxLength: number) => { - return `Password must be between ${minLength} and ${maxLength} characters, and contain at least: - • One lowercase character (any script) - • One uppercase character (any script) - • One number (any numeral system) - • One special character (punctuation or symbol)`; -}; -/** - * The error message for invalid passwords. - */ -export const PASSWORD_REGEX_ERROR = createPasswordRegexError( - MIN_PASSWORD_LENGTH, - MAX_PASSWORD_LENGTH, -); - export default { BCRYPT_ROUNDS, CHILI_PER_HAND, @@ -281,10 +252,8 @@ export default { MIN_PASSWORD_LENGTH, MULTILINGUAL_STRING_REGEX, PASSWORD_REGEX, - PASSWORD_REGEX_ERROR, ROUNDS_TO_WIN, USERNAME_REGEX, - USERNAME_REGEX_ERROR, SITE_DOMAIN, NONE: NONE, }; diff --git a/chili-and-cilantro-lib/src/lib/enumerations/string-names.ts b/chili-and-cilantro-lib/src/lib/enumerations/string-names.ts index 3c90f5e..37aac7b 100644 --- a/chili-and-cilantro-lib/src/lib/enumerations/string-names.ts +++ b/chili-and-cilantro-lib/src/lib/enumerations/string-names.ts @@ -10,22 +10,33 @@ export enum StringNames { ChangePassword_ChangePasswordButton = 'changePassword_changePasswordButton', Common_ChangePassword = 'common_changePassword', Common_CurrentPassword = 'common_currentPassword', + Common_Email = 'common_email', Common_GoToSplash = 'common_goToSplash', Common_Logo = 'common_logo', Common_NewPassword = 'common_newPassword', Common_ConfirmNewPassword = 'common_confirmNewPassword', Common_Dashboard = 'common_dashboard', Common_Loading = 'common_loading', + Common_Password = 'common_password', Common_ReturnToKitchen = 'common_returnToKitchen', Common_Site = 'common_site', Common_StartCooking = 'common_startCooking', Common_Tagline = 'common_tagline', Common_Unauthorized = 'common_unauthorized', Common_UnexpectedError = 'common_unexpectedError', + Common_Username = 'common_username', + Error_AccountStatusIsDeleted = 'error_accountStatusIsDeleted', + Error_AccountStatusIsLocked = 'error_accountStatusIsLocked', + Error_AccountStatusIsPendingEmailVerification = 'error_accountStatusIsPendingEmailVerification', Dashboard_GamesCreated = 'dashboard_gamesCreated', Dashboard_GamesParticipating = 'dashboard_gamesParticipating', Dashboard_NoGames = 'dashboard_noGames', Dashboard_Title = 'dashboard_title', + ForgotPassword_ForgotPassword = 'forgotPassword_forgotPassword', + ForgotPassword_InvalidToken = 'forgotPassword_invalidToken', + ForgotPassword_ResetPassword = 'forgotPassword_resetPassword', + ForgotPassword_SendResetToken = 'forgotPassword_sendResetToken', + ForgotPassword_Success = 'forgotPassword_success', ForgotPassword_Title = 'forgotPassword_title', Game_CreateGame = 'game_createGame', Game_CreateGameSuccess = 'game_createGameSuccess', @@ -43,6 +54,11 @@ export enum StringNames { KeyFeatures_8 = 'keyFeatures_8', LanguageUpdate_Success = 'languageUpdate_success', Login_LoginButton = 'login_loginButton', + Login_Progress = 'login_progress', + Login_ResentPasswordFailure = 'login_resentPasswordFailure', + Login_ResentPasswordSuccess = 'login_resentPasswordSuccess', + Login_Title = 'login_title', + Login_UsernameOrEmailRequired = 'login_usernameOrEmailRequired', LogoutButton = 'logoutButton', RegisterButton = 'registerButton', Splash_Description = 'splash_description', @@ -50,11 +66,14 @@ export enum StringNames { ValidationError = 'validationError', Validation_InvalidEmail = 'validation_invalidEmail', Validation_InvalidLanguage = 'validation_invalidLanguage', + Validation_InvalidTimezone = 'validation_invalidTimezone', Validation_InvalidToken = 'validation_invalidToken', - Validation_PasswordRegexError = 'validation_passwordRegexError', + Validation_PasswordRegexErrorTemplate = 'validation_passwordRegexErrorTemplate', Validation_CurrentPasswordRequired = 'validation_currentPasswordRequired', Validation_PasswordsDifferent = 'validation_passwordsDifferent', Validation_NewPasswordRequired = 'validation_newPasswordRequired', Validation_PasswordMatch = 'validation_passwordMatch', Validation_ConfirmNewPassword = 'validation_confirmNewPassword', + Validation_Required = 'validation_required', + Validation_UsernameRegexErrorTemplate = 'validation_usernameRegexErrorTemplate', } diff --git a/chili-and-cilantro-lib/src/lib/errors/account-deleted.ts b/chili-and-cilantro-lib/src/lib/errors/account-deleted.ts index 3b6dd4c..b081bec 100644 --- a/chili-and-cilantro-lib/src/lib/errors/account-deleted.ts +++ b/chili-and-cilantro-lib/src/lib/errors/account-deleted.ts @@ -1,8 +1,11 @@ -import { HandleableError } from './handleable-error'; +import { AccountStatusTypeEnum } from '../enumerations/account-status-type'; +import { StringNames } from '../enumerations/string-names'; +import { translate } from '../i18n'; +import { AccountStatusError } from './account-status'; -export class AccountDeletedError extends HandleableError { - constructor() { - super('Account has been deleted', { statusCode: 404 }); +export class AccountDeletedError extends AccountStatusError { + constructor(status: AccountStatusTypeEnum) { + super(status, translate(StringNames.Error_AccountStatusIsDeleted), 404); this.name = 'AccountDeletedError'; Object.setPrototypeOf(this, AccountDeletedError.prototype); } diff --git a/chili-and-cilantro-lib/src/lib/errors/account-locked.ts b/chili-and-cilantro-lib/src/lib/errors/account-locked.ts index 2527bd2..65b7d38 100644 --- a/chili-and-cilantro-lib/src/lib/errors/account-locked.ts +++ b/chili-and-cilantro-lib/src/lib/errors/account-locked.ts @@ -1,9 +1,15 @@ import { AccountStatusTypeEnum } from '../enumerations/account-status-type'; +import { StringNames } from '../enumerations/string-names'; +import { translate } from '../i18n'; import { AccountStatusError } from './account-status'; export class AccountLockedError extends AccountStatusError { constructor() { - super(AccountStatusTypeEnum.Locked); + super( + AccountStatusTypeEnum.Locked, + translate(StringNames.Error_AccountStatusIsLocked), + ); + this.name = 'AccountLockedError'; Object.setPrototypeOf(this, AccountLockedError.prototype); } } diff --git a/chili-and-cilantro-lib/src/lib/errors/account-status.ts b/chili-and-cilantro-lib/src/lib/errors/account-status.ts index d53018a..b2e7f6d 100644 --- a/chili-and-cilantro-lib/src/lib/errors/account-status.ts +++ b/chili-and-cilantro-lib/src/lib/errors/account-status.ts @@ -1,9 +1,13 @@ import { AccountStatusTypeEnum } from '../enumerations/account-status-type'; import { HandleableError } from './handleable-error'; -export class AccountStatusError extends HandleableError { - constructor(accountStatus: AccountStatusTypeEnum) { - super(`Account status is ${accountStatus}`, { statusCode: 403 }); +export abstract class AccountStatusError extends HandleableError { + constructor( + accountStatus: AccountStatusTypeEnum, + message: string, + statusCode = 403, + ) { + super(message, { statusCode: statusCode }); this.name = 'AccountStatusError'; Object.setPrototypeOf(this, AccountStatusError.prototype); } diff --git a/chili-and-cilantro-lib/src/lib/errors/invalid-password.ts b/chili-and-cilantro-lib/src/lib/errors/invalid-password.ts index 9a8f65b..f4c1d62 100644 --- a/chili-and-cilantro-lib/src/lib/errors/invalid-password.ts +++ b/chili-and-cilantro-lib/src/lib/errors/invalid-password.ts @@ -1,9 +1,12 @@ -import constants from '../constants'; +import { StringNames } from '../enumerations/string-names'; +import { translate } from '../i18n'; import { HandleableError } from './handleable-error'; export class InvalidPasswordError extends HandleableError { constructor() { - super(constants.PASSWORD_REGEX_ERROR, { statusCode: 401 }); + super(translate(StringNames.Validation_PasswordRegexErrorTemplate), { + statusCode: 401, + }); this.name = 'InvalidPassword'; Object.setPrototypeOf(this, InvalidPasswordError.prototype); } diff --git a/chili-and-cilantro-lib/src/lib/errors/invalid-username.ts b/chili-and-cilantro-lib/src/lib/errors/invalid-username.ts index 0d2a46a..0311633 100644 --- a/chili-and-cilantro-lib/src/lib/errors/invalid-username.ts +++ b/chili-and-cilantro-lib/src/lib/errors/invalid-username.ts @@ -1,9 +1,12 @@ -import constants from '../constants'; +import { StringNames } from '../enumerations/string-names'; +import { translate } from '../i18n'; import { HandleableError } from './handleable-error'; export class InvalidUsernameError extends HandleableError { constructor() { - super(constants.USERNAME_REGEX_ERROR, { statusCode: 400 }); + super(translate(StringNames.Validation_UsernameRegexErrorTemplate), { + statusCode: 400, + }); this.name = 'InvalidUsernameError'; Object.setPrototypeOf(this, InvalidUsernameError.prototype); } diff --git a/chili-and-cilantro-lib/src/lib/errors/pending-email-verification.ts b/chili-and-cilantro-lib/src/lib/errors/pending-email-verification.ts index 1953250..0f542da 100644 --- a/chili-and-cilantro-lib/src/lib/errors/pending-email-verification.ts +++ b/chili-and-cilantro-lib/src/lib/errors/pending-email-verification.ts @@ -1,9 +1,15 @@ import { AccountStatusTypeEnum } from '../enumerations/account-status-type'; +import { StringNames } from '../enumerations/string-names'; +import { translate } from '../i18n'; import { AccountStatusError } from './account-status'; export class PendingEmailVerificationError extends AccountStatusError { constructor() { - super(AccountStatusTypeEnum.NewUnverified); + super( + AccountStatusTypeEnum.NewUnverified, + translate(StringNames.Error_AccountStatusIsPendingEmailVerification), + ); + this.name = 'PendingEmailVerification'; Object.setPrototypeOf(this, PendingEmailVerificationError.prototype); } } diff --git a/chili-and-cilantro-lib/src/lib/i18n.spec.ts b/chili-and-cilantro-lib/src/lib/i18n.spec.ts index 82dd1ac..e1b32ae 100644 --- a/chili-and-cilantro-lib/src/lib/i18n.spec.ts +++ b/chili-and-cilantro-lib/src/lib/i18n.spec.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import constants from './constants'; import { AccountStatusTypeEnum } from './enumerations/account-status-type'; import ActionType from './enumerations/action-type'; import CardType from './enumerations/card-type'; @@ -11,17 +12,18 @@ import { StringLanguages } from './enumerations/string-languages'; import { StringNames } from './enumerations/string-names'; import { TranslatableEnumType } from './enumerations/translatable-enum'; import TurnAction from './enumerations/turn-action'; -import { +import i18nModule, { buildNestedI18n, buildNestedI18nForLanguage, getLanguageCode, GlobalLanguageContext, + replaceVariables, stringNameToI18nKey, - TranslatableEnum, translate, translateEnum, translationsMap, } from './i18n'; +import { TranslatableEnum } from './i18n.types'; import { LanguageCodes } from './language-codes'; import { StringsCollection } from './shared-types'; import { Strings } from './strings'; @@ -187,6 +189,7 @@ describe('translate', () => { afterEach(() => { console.warn = originalConsoleWarn; + jest.restoreAllMocks(); }); it('should return the correct translation for a given string name', () => { @@ -230,6 +233,32 @@ describe('translate', () => { `String ${invalidStringName} not found for language ${StringLanguages.EnglishUS}`, ); }); + + it('should call replaceVariables for StringNames ending with "template"', () => { + const language = StringLanguages.EnglishUS; + const replacedValue = Strings[ + language + ].validation_passwordRegexErrorTemplate + .replace('{MIN_PASSWORD_LENGTH}', `${constants.MIN_PASSWORD_LENGTH}`) + .replace('{MAX_PASSWORD_LENGTH}', `${constants.MAX_PASSWORD_LENGTH}`); + + const result = i18nModule.translate( + StringNames.Validation_PasswordRegexErrorTemplate, + language, + ); + + expect(result).toBe(replacedValue); + }); + + it('should not call replaceVariables for StringNames not ending with "template"', () => { + // Mock the Strings object + const language = StringLanguages.EnglishUS; + const replaceVariablesSpy = jest.spyOn(i18nModule, 'replaceVariables'); + const result = i18nModule.translate(StringNames.Common_Site, language); + + expect(replaceVariablesSpy).not.toHaveBeenCalled(); + expect(result).toBe(Strings[language][StringNames.Common_Site]); + }); }); describe('translateEnum', () => { @@ -391,3 +420,53 @@ describe('getLanguageCode', () => { ); }); }); + +describe('replaceVariables', () => { + it('should replace a single variable with its constant value', () => { + const input = + 'Password must be at least {MIN_PASSWORD_LENGTH} characters long'; + const expected = `Password must be at least ${constants.MIN_PASSWORD_LENGTH} characters long`; + expect(replaceVariables(input)).toBe(expected); + }); + + it('should replace multiple variables with their constant values', () => { + const input = + 'Username must be between {MIN_USERNAME_LENGTH} and {MAX_USERNAME_LENGTH} characters'; + const expected = `Username must be between ${constants.MIN_USERNAME_LENGTH} and ${constants.MAX_USERNAME_LENGTH} characters`; + expect(replaceVariables(input)).toBe(expected); + }); + + it('should return the original string if no variables are found', () => { + const input = 'This string has no variables'; + expect(replaceVariables(input)).toBe(input); + }); + + it('should handle variables at the beginning and end of the string', () => { + const input = + '{MIN_PASSWORD_LENGTH} is the prefix and {MAX_PASSWORD_LENGTH} is the suffix'; + const expected = `${constants.MIN_PASSWORD_LENGTH} is the prefix and ${constants.MAX_PASSWORD_LENGTH} is the suffix`; + expect(replaceVariables(input)).toBe(expected); + }); + + it('should remove unmatched variables', () => { + const input = 'This {NONEXISTENT_VARIABLE} should be removed'; + const expected = 'This should be removed'; + expect(replaceVariables(input)).toBe(expected); + }); + + it('should handle a mix of existing and non-existing variables', () => { + const input = + '{MIN_PASSWORD_LENGTH} is valid, {NONEXISTENT} is not, and {MAX_PASSWORD_LENGTH} is valid again'; + const expected = `${constants.MIN_PASSWORD_LENGTH} is valid, is not, and ${constants.MAX_PASSWORD_LENGTH} is valid again`; + expect(replaceVariables(input)).toBe(expected); + }); + + it('should handle empty strings', () => { + expect(replaceVariables('')).toBe(''); + }); + + it('should handle strings with only unmatched variables', () => { + const input = '{UNMATCHED1} {UNMATCHED2}'; + expect(replaceVariables(input)).toBe(' '); + }); +}); diff --git a/chili-and-cilantro-lib/src/lib/i18n.ts b/chili-and-cilantro-lib/src/lib/i18n.ts index 8a2b3de..9695396 100644 --- a/chili-and-cilantro-lib/src/lib/i18n.ts +++ b/chili-and-cilantro-lib/src/lib/i18n.ts @@ -1,3 +1,4 @@ +import constants from './constants'; import { AccountStatusTypeTranslations } from './enumeration-translations/account-status-type'; import { ActionTypeTranslations } from './enumeration-translations/action-type'; import { ActionTypePastTenseTranslations } from './enumeration-translations/action-type-past-tense'; @@ -10,23 +11,20 @@ import { GamePhaseTranslations } from './enumeration-translations/game-phase'; import { QuitGameReasonTranslations } from './enumeration-translations/quit-game-reason'; import { TurnActionTranslations } from './enumeration-translations/turn-action'; import { TurnActionPastTenseTranslations } from './enumeration-translations/turn-action-past-tense'; -import { AccountStatusTypeEnum } from './enumerations/account-status-type'; -import ActionType from './enumerations/action-type'; -import CardType from './enumerations/card-type'; -import ChallengeResponse from './enumerations/challenge-response'; -import ChefState from './enumerations/chef-state'; -import { EmailTokenType } from './enumerations/email-token-type'; -import GamePhase from './enumerations/game-phase'; -import QuitGameReason from './enumerations/quit-game-reason'; import { StringLanguages } from './enumerations/string-languages'; import { StringNames } from './enumerations/string-names'; import { TranslatableEnumType } from './enumerations/translatable-enum'; -import TurnAction from './enumerations/turn-action'; +import { TranslatableEnum, TranslationsMap } from './i18n.types'; import { ILanguageContext } from './interfaces/language-context'; import { LanguageCodes } from './language-codes'; import { DefaultLanguage, StringsCollection } from './shared-types'; import { Strings } from './strings'; +/** + * Builds a nested object from a flat object. + * @param strings The flat object to build the nested object from + * @returns The nested object + */ export const buildNestedI18n = ( strings: StringsCollection, ): Record => { @@ -63,6 +61,11 @@ export const buildNestedI18n = ( return result; }; +/** + * Builds nested I18n object for a specific language + * @param language The language to build the nested I18n object for + * @returns The nested I18n object + */ export const buildNestedI18nForLanguage = (language: StringLanguages) => { if (!Strings[language]) { throw new Error(`Strings not found for language: ${language}`); @@ -71,9 +74,45 @@ export const buildNestedI18nForLanguage = (language: StringLanguages) => { return buildNestedI18n(Strings[language]); }; +/** + * Replaces underscores with dots + * @param name The string name + * @returns The string name with underscores replaced with dots + */ export const stringNameToI18nKey = (name: StringNames) => name.replace('_', '.'); // only replace the first underscore +/** + * Replaces variables in a string with their corresponding values from the constants object + * @param str The string with variables to replace + * @returns The string with variables replaced + */ +export function replaceVariables(str: string): string { + const variables = str.match(/\{(.+?)\}/g); + if (!variables) { + return str; + } + return variables + .map((variable) => variable.replace('{', '').replace('}', '')) + .reduce( + (acc, variable) => + acc.replace( + `{${variable}}`, + variable in constants + ? (constants as Record)[variable] + : `{${variable}}`, + ), + str, + ) + .replace(/\{(.+?)\}/g, ''); +} + +/** + * Translates a string + * @param name The string name + * @param language The language to translate the string to + * @returns The translated string + */ export const translate = ( name: StringNames, language?: StringLanguages, @@ -87,67 +126,14 @@ export const translate = ( console.warn(`String ${name} not found for language ${lang}`); return name; // Fallback to the string name itself } - return Strings[lang][name]; -}; - -export type TranslatableEnum = - | { type: TranslatableEnumType.AccountStatus; value: AccountStatusTypeEnum } - | { type: TranslatableEnumType.ActionType; value: ActionType } - | { type: TranslatableEnumType.ActionTypePastTense; value: ActionType } - | { type: TranslatableEnumType.CardType; value: CardType } - | { type: TranslatableEnumType.ChallengeResponse; value: ChallengeResponse } - | { - type: TranslatableEnumType.ChallengeResponsePastTense; - value: ChallengeResponse; - } - | { type: TranslatableEnumType.ChefState; value: ChefState } - | { type: TranslatableEnumType.EmailTokenType; value: EmailTokenType } - | { type: TranslatableEnumType.GamePhase; value: GamePhase } - | { type: TranslatableEnumType.QuitGameReason; value: QuitGameReason } - | { type: TranslatableEnumType.TurnAction; value: TurnAction } - | { type: TranslatableEnumType.TurnActionPastTense; value: TurnAction }; - -export type TranslationsMap = { - [TranslatableEnumType.AccountStatus]: { - [key in StringLanguages]: { [key in AccountStatusTypeEnum]: string }; - }; - [TranslatableEnumType.ActionType]: { - [key in StringLanguages]: { [key in ActionType]: string }; - }; - [TranslatableEnumType.ActionTypePastTense]: { - [key in StringLanguages]: { [key in ActionType]: string }; - }; - [TranslatableEnumType.CardType]: { - [key in StringLanguages]: { [key in CardType]: string }; - }; - [TranslatableEnumType.ChallengeResponse]: { - [key in StringLanguages]: { - [key in ChallengeResponse]: string; - }; - }; - [TranslatableEnumType.ChallengeResponsePastTense]: { - [key in StringLanguages]: { [key in ChallengeResponse]: string }; - }; - [TranslatableEnumType.ChefState]: { - [key in StringLanguages]: { [key in ChefState]: string }; - }; - [TranslatableEnumType.EmailTokenType]: { - [key in StringLanguages]: { [key in EmailTokenType]: string }; - }; - [TranslatableEnumType.GamePhase]: { - [key in StringLanguages]: { [key in GamePhase]: string }; - }; - [TranslatableEnumType.QuitGameReason]: { - [key in StringLanguages]: { [key in QuitGameReason]: string }; - }; - [TranslatableEnumType.TurnAction]: { - [key in StringLanguages]: { [key in TurnAction]: string }; - }; - [TranslatableEnumType.TurnActionPastTense]: { - [key in StringLanguages]: { [key in TurnAction]: string }; - }; + return (name as string).toLowerCase().endsWith('template') + ? replaceVariables(Strings[lang][name]) + : Strings[lang][name]; }; +/** + * Translation map + */ export const translationsMap: TranslationsMap = { [TranslatableEnumType.AccountStatus]: AccountStatusTypeTranslations, [TranslatableEnumType.ActionType]: ActionTypeTranslations, @@ -164,6 +150,12 @@ export const translationsMap: TranslationsMap = { [TranslatableEnumType.TurnActionPastTense]: TurnActionPastTenseTranslations, }; +/** + * Translates an enum value + * @param param0 A translatable enum + * @param language The language to translate to + * @returns The translated enum value + */ export const translateEnum = ( { type, value }: TranslatableEnum, language?: StringLanguages, @@ -181,10 +173,18 @@ export const translateEnum = ( ); }; +/** + * Global language context + */ export const GlobalLanguageContext: ILanguageContext = { language: DefaultLanguage, }; +/** + * Gets the language code from a language name + * @param language The language name + * @returns The language code + */ export function getLanguageCode(language: string): StringLanguages { for (const [key, value] of Object.entries(LanguageCodes)) { if (value === language) { @@ -193,3 +193,16 @@ export function getLanguageCode(language: string): StringLanguages { } throw new Error(`Unknown language code: ${language}`); } + +export default { + buildNestedI18n, + buildNestedI18nForLanguage, + stringNameToI18nKey, + translate, + translateEnum, + GlobalLanguageContext, + getLanguageCode, + replaceVariables, + translationsMap, + TranslatableEnumType, +}; diff --git a/chili-and-cilantro-lib/src/lib/i18n.types.ts b/chili-and-cilantro-lib/src/lib/i18n.types.ts new file mode 100644 index 0000000..84757ac --- /dev/null +++ b/chili-and-cilantro-lib/src/lib/i18n.types.ts @@ -0,0 +1,75 @@ +import { AccountStatusTypeEnum } from './enumerations/account-status-type'; +import ActionType from './enumerations/action-type'; +import CardType from './enumerations/card-type'; +import ChallengeResponse from './enumerations/challenge-response'; +import ChefState from './enumerations/chef-state'; +import { EmailTokenType } from './enumerations/email-token-type'; +import GamePhase from './enumerations/game-phase'; +import QuitGameReason from './enumerations/quit-game-reason'; +import { StringLanguages } from './enumerations/string-languages'; +import { TranslatableEnumType } from './enumerations/translatable-enum'; +import TurnAction from './enumerations/turn-action'; + +/** + * Enums that can be translated + */ +export type TranslatableEnum = + | { type: TranslatableEnumType.AccountStatus; value: AccountStatusTypeEnum } + | { type: TranslatableEnumType.ActionType; value: ActionType } + | { type: TranslatableEnumType.ActionTypePastTense; value: ActionType } + | { type: TranslatableEnumType.CardType; value: CardType } + | { type: TranslatableEnumType.ChallengeResponse; value: ChallengeResponse } + | { + type: TranslatableEnumType.ChallengeResponsePastTense; + value: ChallengeResponse; + } + | { type: TranslatableEnumType.ChefState; value: ChefState } + | { type: TranslatableEnumType.EmailTokenType; value: EmailTokenType } + | { type: TranslatableEnumType.GamePhase; value: GamePhase } + | { type: TranslatableEnumType.QuitGameReason; value: QuitGameReason } + | { type: TranslatableEnumType.TurnAction; value: TurnAction } + | { type: TranslatableEnumType.TurnActionPastTense; value: TurnAction }; + +/** + * Translations map + */ +export type TranslationsMap = { + [TranslatableEnumType.AccountStatus]: { + [key in StringLanguages]: { [key in AccountStatusTypeEnum]: string }; + }; + [TranslatableEnumType.ActionType]: { + [key in StringLanguages]: { [key in ActionType]: string }; + }; + [TranslatableEnumType.ActionTypePastTense]: { + [key in StringLanguages]: { [key in ActionType]: string }; + }; + [TranslatableEnumType.CardType]: { + [key in StringLanguages]: { [key in CardType]: string }; + }; + [TranslatableEnumType.ChallengeResponse]: { + [key in StringLanguages]: { + [key in ChallengeResponse]: string; + }; + }; + [TranslatableEnumType.ChallengeResponsePastTense]: { + [key in StringLanguages]: { [key in ChallengeResponse]: string }; + }; + [TranslatableEnumType.ChefState]: { + [key in StringLanguages]: { [key in ChefState]: string }; + }; + [TranslatableEnumType.EmailTokenType]: { + [key in StringLanguages]: { [key in EmailTokenType]: string }; + }; + [TranslatableEnumType.GamePhase]: { + [key in StringLanguages]: { [key in GamePhase]: string }; + }; + [TranslatableEnumType.QuitGameReason]: { + [key in StringLanguages]: { [key in QuitGameReason]: string }; + }; + [TranslatableEnumType.TurnAction]: { + [key in StringLanguages]: { [key in TurnAction]: string }; + }; + [TranslatableEnumType.TurnActionPastTense]: { + [key in StringLanguages]: { [key in TurnAction]: string }; + }; +}; diff --git a/chili-and-cilantro-lib/src/lib/strings/english-uk.ts b/chili-and-cilantro-lib/src/lib/strings/english-uk.ts index c920cf2..086f12f 100644 --- a/chili-and-cilantro-lib/src/lib/strings/english-uk.ts +++ b/chili-and-cilantro-lib/src/lib/strings/english-uk.ts @@ -11,21 +11,35 @@ export const BritishEnglishStrings: StringsCollection = { [StringNames.Common_ChangePassword]: 'Change Password', [StringNames.Common_ConfirmNewPassword]: 'Confirm New Password', [StringNames.Common_CurrentPassword]: 'Current Password', + [StringNames.Common_Email]: 'Email', [StringNames.Common_GoToSplash]: 'Return to Welcome Screen', [StringNames.Common_NewPassword]: 'New Password', [StringNames.Common_Dashboard]: 'Dashboard', [StringNames.Common_Loading]: 'Loading...', [StringNames.Common_Logo]: 'logo', + [StringNames.Common_Password]: 'Password', [StringNames.Common_ReturnToKitchen]: 'Return to Kitchen', [StringNames.Common_Site]: site, [StringNames.Common_StartCooking]: 'Start Cooking', [StringNames.Common_Tagline]: 'A Spicy Bluffing Game', [StringNames.Common_Unauthorized]: 'Unauthorized', [StringNames.Common_UnexpectedError]: 'Unexpected Error', + [StringNames.Common_Username]: 'Username', + [StringNames.Error_AccountStatusIsDeleted]: 'Account deleted', + [StringNames.Error_AccountStatusIsLocked]: 'Account locked', + [StringNames.Error_AccountStatusIsPendingEmailVerification]: + 'Account pending email verification', [StringNames.Dashboard_GamesCreated]: "Games You've Created", [StringNames.Dashboard_GamesParticipating]: "Games You're Participating in", [StringNames.Dashboard_NoGames]: 'No games available.', [StringNames.Dashboard_Title]: 'Your Dashboard', + [StringNames.ForgotPassword_ForgotPassword]: 'Forgot Password', + [StringNames.ForgotPassword_InvalidToken]: + 'Invalid or expired token. Please request a new password reset.', + [StringNames.ForgotPassword_ResetPassword]: 'Reset Password', + [StringNames.ForgotPassword_SendResetToken]: 'Send Reset Email', + [StringNames.ForgotPassword_Success]: + 'Your password has been successfully reset. You can now log in with your new password.', [StringNames.ForgotPassword_Title]: 'Forgot Password', [StringNames.Game_CreateGame]: 'Create Game', [StringNames.Game_CreateGameSuccess]: 'Game created successfully', @@ -45,6 +59,14 @@ export const BritishEnglishStrings: StringsCollection = { [StringNames.KeyFeatures_8]: 'Perfect for game nights and family gatherings', [StringNames.LanguageUpdate_Success]: 'Language updated successfully', [StringNames.Login_LoginButton]: 'Login', + [StringNames.Login_Progress]: 'Logging in...', + [StringNames.Login_ResentPasswordFailure]: + 'Failed to resend verification email', + [StringNames.Login_ResentPasswordSuccess]: + 'Verification email sent successfully', + [StringNames.Login_Title]: 'Login', + [StringNames.Login_UsernameOrEmailRequired]: + 'Either username or email is required', [StringNames.LogoutButton]: 'Logout', [StringNames.RegisterButton]: 'Register', [StringNames.Splash_Description]: @@ -53,9 +75,13 @@ export const BritishEnglishStrings: StringsCollection = { [StringNames.ValidationError]: 'Validation Error', [StringNames.Validation_InvalidEmail]: 'Invalid Email', [StringNames.Validation_InvalidLanguage]: 'Invalid Language', + [StringNames.Validation_InvalidTimezone]: 'Invalid Timezone', [StringNames.Validation_InvalidToken]: 'Invalid Token', - [StringNames.Validation_PasswordRegexError]: - 'Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};\':"|,.<>/?)', + [StringNames.Validation_PasswordRegexErrorTemplate]: `Password must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH} characters, and contain at least: + • One lowercase character (any script) + • One uppercase character (any script) + • One number (any numeral system) + • One special character (punctuation or symbol)`, [StringNames.Validation_CurrentPasswordRequired]: 'Current password is required', [StringNames.Validation_PasswordsDifferent]: @@ -64,6 +90,11 @@ export const BritishEnglishStrings: StringsCollection = { [StringNames.Validation_PasswordMatch]: 'Password and confirm must match', [StringNames.Validation_ConfirmNewPassword]: 'Confirm new password is required', + [StringNames.Validation_Required]: 'Required', + [StringNames.Validation_UsernameRegexErrorTemplate]: `Username must be between {MIN_USERNAME_LENGTH} and {MAX_USERNAME_LENGTH} characters, and: + • Start with a letter, number, or Unicode character + • Can contain letters, numbers, underscores, hyphens, and Unicode characters + • Cannot contain spaces or special characters other than underscores and hyphens`, }; export default BritishEnglishStrings; diff --git a/chili-and-cilantro-lib/src/lib/strings/english-us.ts b/chili-and-cilantro-lib/src/lib/strings/english-us.ts index 11bddc3..0732bcb 100644 --- a/chili-and-cilantro-lib/src/lib/strings/english-us.ts +++ b/chili-and-cilantro-lib/src/lib/strings/english-us.ts @@ -11,21 +11,35 @@ export const AmericanEnglishStrings: StringsCollection = { [StringNames.Common_ChangePassword]: 'Change Password', [StringNames.Common_ConfirmNewPassword]: 'Confirm New Password', [StringNames.Common_CurrentPassword]: 'Current Password', + [StringNames.Common_Email]: 'Email', [StringNames.Common_GoToSplash]: 'Return to Welcome Screen', [StringNames.Common_NewPassword]: 'New Password', [StringNames.Common_Dashboard]: 'Dashboard', [StringNames.Common_Loading]: 'Loading...', [StringNames.Common_Logo]: 'logo', + [StringNames.Common_Password]: 'Password', [StringNames.Common_ReturnToKitchen]: 'Return to Kitchen', [StringNames.Common_Site]: site, [StringNames.Common_StartCooking]: 'Start Cooking', [StringNames.Common_Tagline]: 'A Spicy Bluffing Game', [StringNames.Common_Unauthorized]: 'Unauthorized', [StringNames.Common_UnexpectedError]: 'Unexpected Error', + [StringNames.Common_Username]: 'Username', + [StringNames.Error_AccountStatusIsDeleted]: 'Account deleted', + [StringNames.Error_AccountStatusIsLocked]: 'Account locked', + [StringNames.Error_AccountStatusIsPendingEmailVerification]: + 'Account pending email verification', [StringNames.Dashboard_GamesCreated]: "Games You've Created", [StringNames.Dashboard_GamesParticipating]: "Games You're Participating in", [StringNames.Dashboard_NoGames]: 'No games available.', [StringNames.Dashboard_Title]: 'Your Dashboard', + [StringNames.ForgotPassword_ForgotPassword]: 'Forgot Password', + [StringNames.ForgotPassword_InvalidToken]: + 'Invalid or expired token. Please request a new password reset.', + [StringNames.ForgotPassword_ResetPassword]: 'Reset Password', + [StringNames.ForgotPassword_SendResetToken]: 'Send Reset Email', + [StringNames.ForgotPassword_Success]: + 'Your password has been successfully reset. You can now log in with your new password.', [StringNames.ForgotPassword_Title]: 'Forgot Password', [StringNames.Game_CreateGame]: 'Create Game', [StringNames.Game_CreateGameSuccess]: 'Game created successfully', @@ -45,6 +59,14 @@ export const AmericanEnglishStrings: StringsCollection = { [StringNames.KeyFeatures_8]: 'Perfect for game nights and family gatherings', [StringNames.LanguageUpdate_Success]: 'Language updated successfully', [StringNames.Login_LoginButton]: 'Login', + [StringNames.Login_Progress]: 'Logging in...', + [StringNames.Login_ResentPasswordFailure]: + 'Failed to resend verification email', + [StringNames.Login_ResentPasswordSuccess]: + 'Verification email sent successfully', + [StringNames.Login_Title]: 'Login', + [StringNames.Login_UsernameOrEmailRequired]: + 'Either username or email is required', [StringNames.LogoutButton]: 'Logout', [StringNames.RegisterButton]: 'Register', [StringNames.Splash_Description]: @@ -53,9 +75,13 @@ export const AmericanEnglishStrings: StringsCollection = { [StringNames.ValidationError]: 'Validation Error', [StringNames.Validation_InvalidEmail]: 'Invalid Email', [StringNames.Validation_InvalidLanguage]: 'Invalid Language', + [StringNames.Validation_InvalidTimezone]: 'Invalid Timezone', [StringNames.Validation_InvalidToken]: 'Invalid Token', - [StringNames.Validation_PasswordRegexError]: - 'Password must be at least 8 characters long and include at least one letter, one number, and one special character (!@#$%^&*()_+-=[]{};\':"|,.<>/?)', + [StringNames.Validation_PasswordRegexErrorTemplate]: `Password must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH} characters, and contain at least: + • One lowercase character (any script) + • One uppercase character (any script) + • One number (any numeral system) + • One special character (punctuation or symbol)`, [StringNames.Validation_CurrentPasswordRequired]: 'Current password is required', [StringNames.Validation_PasswordsDifferent]: @@ -64,6 +90,11 @@ export const AmericanEnglishStrings: StringsCollection = { [StringNames.Validation_PasswordMatch]: 'Password and confirm must match', [StringNames.Validation_ConfirmNewPassword]: 'Confirm new password is required', + [StringNames.Validation_Required]: 'Required', + [StringNames.Validation_UsernameRegexErrorTemplate]: `Username must be between {MIN_USERNAME_LENGTH} and {MAX_USERNAME_LENGTH} characters, and: + • Start with a letter, number, or Unicode character + • Can contain letters, numbers, underscores, hyphens, and Unicode characters + • Cannot contain spaces or special characters other than underscores and hyphens`, }; export default AmericanEnglishStrings; diff --git a/chili-and-cilantro-lib/src/lib/strings/french.ts b/chili-and-cilantro-lib/src/lib/strings/french.ts index 420f54e..fd18bba 100644 --- a/chili-and-cilantro-lib/src/lib/strings/french.ts +++ b/chili-and-cilantro-lib/src/lib/strings/french.ts @@ -14,18 +14,33 @@ export const FrenchStrings: StringsCollection = { [StringNames.Common_GoToSplash]: "Retourner aucran d'accueil", [StringNames.Common_NewPassword]: 'Nouveau mot de passe', [StringNames.Common_Dashboard]: 'Tableau de bord', + [StringNames.Common_Email]: 'Courriel', [StringNames.Common_Loading]: 'Chargement...', [StringNames.Common_Logo]: 'logo', + [StringNames.Common_Password]: 'Mot de passe', [StringNames.Common_ReturnToKitchen]: 'Retour à la Cuisine', [StringNames.Common_Site]: site, [StringNames.Common_StartCooking]: 'Commencer la Cuisine', [StringNames.Common_Tagline]: 'Un Jeu de Bluff Épicé', [StringNames.Common_Unauthorized]: 'Non autorisé', [StringNames.Common_UnexpectedError]: 'Erreur inattendue', + [StringNames.Common_Username]: 'Nom d’utilisateur', + [StringNames.Error_AccountStatusIsDeleted]: 'Compte supprimé', + [StringNames.Error_AccountStatusIsLocked]: 'Compte verrouillé', + [StringNames.Error_AccountStatusIsPendingEmailVerification]: + 'Compte en attente de confirmation de courriel', [StringNames.Dashboard_GamesCreated]: 'Jeux que vous avez créé', [StringNames.Dashboard_GamesParticipating]: 'Jeux que vous participez', [StringNames.Dashboard_NoGames]: 'Aucun jeu disponible.', [StringNames.Dashboard_Title]: 'Votre Tableau de Bord', + [StringNames.ForgotPassword_ForgotPassword]: 'Mot de passe oublié', + [StringNames.ForgotPassword_InvalidToken]: + 'Jeton invalide ou expiré. Veuillez demander un nouveau mot de passe.', + [StringNames.ForgotPassword_ResetPassword]: 'Réinitialiser le mot de passe', + [StringNames.ForgotPassword_SendResetToken]: + 'Envoyer le mot de passe par courriel', + [StringNames.ForgotPassword_Success]: + 'Votre mot de passe a bien été changé. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.', [StringNames.ForgotPassword_Title]: 'Mot de passe oublié', [StringNames.Game_CreateGame]: 'Créer un Jeu', [StringNames.Game_CreateGameSuccess]: 'Jeu créé avec succès', @@ -48,6 +63,14 @@ export const FrenchStrings: StringsCollection = { 'Parfait pour les soirées de jeux et les réunions de famille', [StringNames.LanguageUpdate_Success]: 'Langue mise à jour avec succès', [StringNames.Login_LoginButton]: 'Connexion', + [StringNames.Login_Progress]: 'Connexion en cours...', + [StringNames.Login_ResentPasswordFailure]: + 'Échec de l’envoi du courriel de verification', + [StringNames.Login_ResentPasswordSuccess]: + 'Courriel de verification envoyé avec успех', + [StringNames.Login_Title]: 'Connexion', + [StringNames.Login_UsernameOrEmailRequired]: + 'Nom d’utilisateur ou courriel requis', [StringNames.LogoutButton]: 'Déconnexion', [StringNames.RegisterButton]: "S'inscrire", [StringNames.Splash_Description]: @@ -56,9 +79,14 @@ export const FrenchStrings: StringsCollection = { [StringNames.ValidationError]: 'Erreur de validation', [StringNames.Validation_InvalidEmail]: 'Courriel invalide', [StringNames.Validation_InvalidLanguage]: 'Langue invalide', + [StringNames.Validation_InvalidTimezone]: 'Fuseau horaire invalide', [StringNames.Validation_InvalidToken]: 'Jeton invalide', - [StringNames.Validation_PasswordRegexError]: - 'Le mot de passe doit comporter au moins 8 caractères et inclure au moins une lettre, un chiffre et un caractère spécial (!@#$%^&*()_+-=[]{};\':"|,.<>/?)', + [StringNames.Validation_PasswordRegexErrorTemplate]: `Le mot de passe doit contenir entre {MIN_PASSWORD_LENGTH} et {MAX_PASSWORD_LENGTH} caractères, et contenir au moins : + • Une lettre minuscule (tout script) + • Une lettre majuscule (tout script) + • Un chiffre (tout système numérique) + • Un caractère spécial (ponctuation ou symbole)`, + [StringNames.Validation_CurrentPasswordRequired]: 'Le mot de passe actuel est requis', [StringNames.Validation_PasswordsDifferent]: @@ -69,6 +97,11 @@ export const FrenchStrings: StringsCollection = { 'Le mot de passe et la confirmation doivent correspondre', [StringNames.Validation_ConfirmNewPassword]: 'La confirmation du nouveau mot de passe est requise', + [StringNames.Validation_Required]: 'Champ obligatoire', + [StringNames.Validation_UsernameRegexErrorTemplate]: `Le nom d'utilisateur doit contenir entre {MIN_USERNAME_LENGTH} et {MAX_USERNAME_LENGTH} caractères, et : + • Commencer par une lettre, un chiffre, ou un caractère Unicode + • Peut contenir des lettres, des chiffres, des underscores, des tirets, et des caractères Unicode + • Ne peut contenir des espaces ou des caractères spéciaux autre que des underscores et des tirets`, }; export default FrenchStrings; diff --git a/chili-and-cilantro-lib/src/lib/strings/mandarin.ts b/chili-and-cilantro-lib/src/lib/strings/mandarin.ts index 82bc018..128ce3d 100644 --- a/chili-and-cilantro-lib/src/lib/strings/mandarin.ts +++ b/chili-and-cilantro-lib/src/lib/strings/mandarin.ts @@ -10,21 +10,35 @@ export const MandarinStrings: StringsCollection = { [StringNames.Common_ChangePassword]: '更改密码', [StringNames.Common_ConfirmNewPassword]: '确认新密码', [StringNames.Common_CurrentPassword]: '当前密码', + [StringNames.Common_Email]: '电子邮件', [StringNames.Common_GoToSplash]: '返回欢迎屏幕', [StringNames.Common_NewPassword]: '新密码', [StringNames.Common_Dashboard]: '仪表板', [StringNames.Common_Loading]: '加载中...', [StringNames.Common_Logo]: '标志', + [StringNames.Common_Password]: '密码', [StringNames.Common_ReturnToKitchen]: '返回厨房', [StringNames.Common_Site]: site, [StringNames.Common_StartCooking]: '开始烹饪', [StringNames.Common_Tagline]: '一款辛辣的虚张声势游戏', [StringNames.Common_Unauthorized]: '未经授权', [StringNames.Common_UnexpectedError]: '意外错误', + [StringNames.Common_Username]: '用户名', + [StringNames.Error_AccountStatusIsDeleted]: '帐户已删除', + [StringNames.Error_AccountStatusIsLocked]: '帐户已锁定', + [StringNames.Error_AccountStatusIsPendingEmailVerification]: + '帐户待电子邮件验证', [StringNames.Dashboard_GamesCreated]: '您创建的游戏', [StringNames.Dashboard_GamesParticipating]: '您参与的游戏', [StringNames.Dashboard_NoGames]: '没有可用的游戏。', [StringNames.Dashboard_Title]: '您的仪表板', + [StringNames.ForgotPassword_ForgotPassword]: '忘记密码', + [StringNames.ForgotPassword_InvalidToken]: + '无效或过期的令牌。请请求新密码重置。', + [StringNames.ForgotPassword_ResetPassword]: '重置密码', + [StringNames.ForgotPassword_SendResetToken]: '发送重置电子邮件', + [StringNames.ForgotPassword_Success]: + '您的密码已成功重置。您现在可以使用新密码登录。', [StringNames.ForgotPassword_Title]: '忘记密码', [StringNames.Game_CreateGame]: '创建游戏', [StringNames.Game_CreateGameSuccess]: '成功创建游戏', @@ -42,6 +56,11 @@ export const MandarinStrings: StringsCollection = { [StringNames.KeyFeatures_8]: '适合游戏之夜和家庭聚会', [StringNames.LanguageUpdate_Success]: '语言更新成功', [StringNames.Login_LoginButton]: '登录', + [StringNames.Login_Progress]: '登录中...', + [StringNames.Login_ResentPasswordFailure]: '重新发送密码失败', + [StringNames.Login_ResentPasswordSuccess]: '重发密码成功', + [StringNames.Login_Title]: '登录', + [StringNames.Login_UsernameOrEmailRequired]: '需要用户名或电子邮件', [StringNames.LogoutButton]: '注销', [StringNames.RegisterButton]: '注册', [StringNames.Splash_Description]: @@ -50,14 +69,23 @@ export const MandarinStrings: StringsCollection = { [StringNames.ValidationError]: '验证错误', [StringNames.Validation_InvalidEmail]: '无效电子邮件', [StringNames.Validation_InvalidLanguage]: '无效语言', + [StringNames.Validation_InvalidTimezone]: '无效时区', [StringNames.Validation_InvalidToken]: '无效令牌', - [StringNames.Validation_PasswordRegexError]: - '密码必须至少8个字符长且包含至少一个字母、一个数字和一个特殊字符(!@#$%^&*()_+-=[]{};:"|,.<>/?)', + [StringNames.Validation_PasswordRegexErrorTemplate]: `密码必须在{MIN_PASSWORD_LENGTH}和{MAX_PASSWORD_LENGTH}个字符之间,并且至少包含: + • 一个小写字母(任何脚本) + • 一个大写字母(任何脚本) + • 一个数字(任何数字系统) + • 一个特殊字符(标点符号或符号)`, [StringNames.Validation_CurrentPasswordRequired]: '当前密码是必需的', [StringNames.Validation_PasswordsDifferent]: '新密码必须不同于当前密码', [StringNames.Validation_NewPasswordRequired]: '新密码是必需的', [StringNames.Validation_PasswordMatch]: '密码和确认必须匹配', [StringNames.Validation_ConfirmNewPassword]: '确认新密码是必需的', + [StringNames.Validation_Required]: '必填', + [StringNames.Validation_UsernameRegexErrorTemplate]: `用户名必须在{MIN_USERNAME_LENGTH}和{MAX_USERNAME_LENGTH}个字符之间,并且: + • 以字母,数字,或Unicode字符开头 + • 可以包含字母,数字,下划线,短横线,或Unicode字符 + • 不能包含空格或特殊字符,除下划线和短横线外`, }; export default MandarinStrings; diff --git a/chili-and-cilantro-lib/src/lib/strings/spanish.ts b/chili-and-cilantro-lib/src/lib/strings/spanish.ts index cd3dcab..02fafde 100644 --- a/chili-and-cilantro-lib/src/lib/strings/spanish.ts +++ b/chili-and-cilantro-lib/src/lib/strings/spanish.ts @@ -11,21 +11,36 @@ export const SpanishStrings: StringsCollection = { [StringNames.Common_ChangePassword]: 'Cambiar contraseña', [StringNames.Common_ConfirmNewPassword]: 'Confirmar nueva contraseña', [StringNames.Common_CurrentPassword]: 'Contraseña actual', + [StringNames.Common_Email]: 'Correo electrónica', [StringNames.Common_GoToSplash]: 'Volver a la pantalla de bienvenida', [StringNames.Common_NewPassword]: 'Nueva contraseña', [StringNames.Common_Dashboard]: 'Tablero', [StringNames.Common_Loading]: 'Cargando...', [StringNames.Common_Logo]: 'logo', + [StringNames.Common_Password]: 'Contraseña', [StringNames.Common_ReturnToKitchen]: 'Volver a la Cocina', [StringNames.Common_Site]: site, [StringNames.Common_StartCooking]: 'Comenzar a cocinar', [StringNames.Common_Tagline]: 'Un Juego de Bluff Picante', [StringNames.Common_Unauthorized]: 'No autorizado', [StringNames.Common_UnexpectedError]: 'Error inesperado', + [StringNames.Common_Username]: 'Nombre de usuario', + [StringNames.Error_AccountStatusIsDeleted]: 'Cuenta eliminada', + [StringNames.Error_AccountStatusIsLocked]: 'Cuenta bloqueada', + [StringNames.Error_AccountStatusIsPendingEmailVerification]: + 'Cuenta en espera de verificación de correo electrónico', [StringNames.Dashboard_GamesCreated]: 'Juegos que has creado', [StringNames.Dashboard_GamesParticipating]: 'Juegos en los que participas', [StringNames.Dashboard_NoGames]: 'No hay juegos disponibles.', [StringNames.Dashboard_Title]: 'Tu Tablero', + [StringNames.ForgotPassword_ForgotPassword]: 'Olvidé mi contraseña', + [StringNames.ForgotPassword_InvalidToken]: + 'Token inválido o expirado. Por favor, solicita un nuevo restablecimiento de contraseña.', + [StringNames.ForgotPassword_ResetPassword]: 'Restablecer contraseña', + [StringNames.ForgotPassword_SendResetToken]: + 'Enviar restablecimiento de contraseña', + [StringNames.ForgotPassword_Success]: + 'Tu contraseña ha sido restablecida con éxito. Ahora puedes usar tu nueva contraseña para ingresar.', [StringNames.ForgotPassword_Title]: 'Contraseña olvidada', [StringNames.Game_CreateGame]: 'Crear juego', [StringNames.Game_CreateGameSuccess]: 'Juego creado con éxito', @@ -46,6 +61,12 @@ export const SpanishStrings: StringsCollection = { 'Perfecto para noches de juegos y reuniones familiares', [StringNames.LanguageUpdate_Success]: 'Idioma actualizado con éxito', [StringNames.Login_LoginButton]: 'Iniciar sesión', + [StringNames.Login_Progress]: 'Iniciando sesión...', + [StringNames.Login_ResentPasswordFailure]: 'Reenviar contraseña fallido', + [StringNames.Login_ResentPasswordSuccess]: 'Reenviar contraseña con éxito', + [StringNames.Login_Title]: 'Iniciar sesión', + [StringNames.Login_UsernameOrEmailRequired]: + 'Se requiere un nombre de usuario o correo electrónica', [StringNames.LogoutButton]: 'Cerrar sesión', [StringNames.RegisterButton]: 'Registrarse', [StringNames.Splash_Description]: @@ -54,9 +75,13 @@ export const SpanishStrings: StringsCollection = { [StringNames.ValidationError]: 'Error de validación', [StringNames.Validation_InvalidEmail]: 'Correo electrónica inválido', [StringNames.Validation_InvalidLanguage]: 'Idioma inválido', + [StringNames.Validation_InvalidTimezone]: 'Fuseau horaire invático', [StringNames.Validation_InvalidToken]: 'Token inválido', - [StringNames.Validation_PasswordRegexError]: - 'La contraseña debe tener al menos 8 caracteres y incluir al menos una letra, un número y un carácter especial (!@#$%^&*()_+-=[]{};\':"|,.<>/?)', + [StringNames.Validation_PasswordRegexErrorTemplate]: `La contraseña debe estar entre {MIN_PASSWORD_LENGTH} y {MAX_PASSWORD_LENGTH} caracteres, y contener al menos: + • Una letra minuscula (cualquier script) + • Una letra mayúscula (cualquier script) + • Un número (cualquier sistema numérico) + • Un caracter especial (puntuación o símbolo)`, [StringNames.Validation_CurrentPasswordRequired]: 'Se requiere la contraseña actual', [StringNames.Validation_PasswordsDifferent]: @@ -67,6 +92,11 @@ export const SpanishStrings: StringsCollection = { 'La contraseña y la confirmación deben coincidir', [StringNames.Validation_ConfirmNewPassword]: 'Se requiere la confirmación de la nueva contraseña', + [StringNames.Validation_Required]: 'Requerido', + [StringNames.Validation_UsernameRegexErrorTemplate]: `El nombre de usuario debe tener entre {MIN_USERNAME_LENGTH} y {MAX_USERNAME_LENGTH} caracteres, y: + • Comenzar con una letra, un número, o un carácter Unicode + • Puede contener letras, números, guiones bajos, guiones, y carácteres Unicode + • No puede contener espacios o carácteres especiales excepto guiones bajos y guiones`, }; export default SpanishStrings; diff --git a/chili-and-cilantro-lib/src/lib/strings/ukrainian.ts b/chili-and-cilantro-lib/src/lib/strings/ukrainian.ts index d4f8145..0ba887f 100644 --- a/chili-and-cilantro-lib/src/lib/strings/ukrainian.ts +++ b/chili-and-cilantro-lib/src/lib/strings/ukrainian.ts @@ -11,21 +11,36 @@ export const UkrainianStrings: StringsCollection = { [StringNames.Common_ChangePassword]: 'Змінити пароль', [StringNames.Common_ConfirmNewPassword]: 'Підтвердити новий пароль', [StringNames.Common_CurrentPassword]: 'Поточний пароль', + [StringNames.Common_Email]: 'Електронна пошта', [StringNames.Common_GoToSplash]: 'Повернутися до екрану привітання', [StringNames.Common_NewPassword]: 'Новий пароль', [StringNames.Common_Dashboard]: 'Панель', [StringNames.Common_Loading]: 'Завантаження...', [StringNames.Common_Logo]: 'лого', + [StringNames.Common_Password]: 'Пароль', [StringNames.Common_ReturnToKitchen]: 'Повернутися на Кухню', [StringNames.Common_Site]: site, [StringNames.Common_StartCooking]: 'Почати приготування', [StringNames.Common_Tagline]: 'Пікантний Блеф', [StringNames.Common_Unauthorized]: 'Немає авторизації', [StringNames.Common_UnexpectedError]: 'Неочікувана помилка', + [StringNames.Common_Username]: 'Ім’я користувача', + [StringNames.Error_AccountStatusIsDeleted]: 'Обліковий запис видалено', + [StringNames.Error_AccountStatusIsLocked]: 'Обліковий запис заблоковано', + [StringNames.Error_AccountStatusIsPendingEmailVerification]: + 'Обліковий запис очікує підтвердження електронної пошти', [StringNames.Dashboard_GamesCreated]: 'Гри, які ти створив', [StringNames.Dashboard_GamesParticipating]: 'Гри, в яких ти береш участь', [StringNames.Dashboard_NoGames]: 'Немає доступних ігор.', [StringNames.Dashboard_Title]: 'Ваша Панель', + [StringNames.ForgotPassword_ForgotPassword]: 'Забули пароль?', + [StringNames.ForgotPassword_InvalidToken]: + 'Недіїдженний токен. Будь ласка, зверніться до підтримки.', + [StringNames.ForgotPassword_ResetPassword]: 'Скинути пароль', + [StringNames.ForgotPassword_SendResetToken]: + 'Відправити лист зі скиданням пароля', + [StringNames.ForgotPassword_Success]: + 'Ваш пароль успішно змінено. Тепер ви можете використовувати новий пароль для входу.', [StringNames.ForgotPassword_Title]: 'Забули пароль', [StringNames.Game_CreateGame]: 'Створити гру', [StringNames.Game_CreateGameSuccess]: 'Гру успішно створено', @@ -46,6 +61,14 @@ export const UkrainianStrings: StringsCollection = { 'Ідеально підходить для вечірніх ігор і сімейних зустрічей', [StringNames.LanguageUpdate_Success]: 'Мова оновлена успішно', [StringNames.Login_LoginButton]: 'Увійти', + [StringNames.Login_Progress]: 'Вхід...', + [StringNames.Login_ResentPasswordFailure]: + 'Невдалося відправити новий пароль', + [StringNames.Login_ResentPasswordSuccess]: + 'Новиий пароль успішно відправлено', + [StringNames.Login_Title]: 'Увійти', + [StringNames.Login_UsernameOrEmailRequired]: + 'Необхідно вказати ім’я користувача або електронну пошту', [StringNames.LogoutButton]: 'Вийти', [StringNames.RegisterButton]: 'Зареєструватися', [StringNames.Splash_Description]: @@ -54,9 +77,13 @@ export const UkrainianStrings: StringsCollection = { [StringNames.ValidationError]: 'Помилка валідації', [StringNames.Validation_InvalidEmail]: 'Недійсний електронній адрес', [StringNames.Validation_InvalidLanguage]: 'Недійсна мова', + [StringNames.Validation_InvalidTimezone]: 'Недійсна часова зона', [StringNames.Validation_InvalidToken]: 'Недійсний токен', - [StringNames.Validation_PasswordRegexError]: - 'Пароль повинен містити принаймні 8 символів, включаючи принаймні одну літеру, одну цифру та один спеціальний символ (!@#$%^&*()_+-=[]{};\':"|,.<>/?)', + [StringNames.Validation_PasswordRegexErrorTemplate]: `Пароль повинен бути від {MIN_PASSWORD_LENGTH} до {MAX_PASSWORD_LENGTH} символів, та містити як мінімум: + • Одну нижню літеру (всіх скриптів) + • Одну верхню літеру (всіх скриптів) + • Одну цифру (будь-яку систему числення) + • Один спеціальний символ (пунктуацію або символ)`, [StringNames.Validation_CurrentPasswordRequired]: 'Поточний пароль є обов’язковим', [StringNames.Validation_PasswordsDifferent]: @@ -66,6 +93,11 @@ export const UkrainianStrings: StringsCollection = { 'Пароль та підтвердження повинні співпадати', [StringNames.Validation_ConfirmNewPassword]: 'Підтвердження нового пароля є обов’язковим', + [StringNames.Validation_Required]: 'Обов’язково', + [StringNames.Validation_UsernameRegexErrorTemplate]: `Ім’я користувача повинно бути від {MIN_USERNAME_LENGTH} до {MAX_USERNAME_LENGTH} символів, та: + • Починатися з літери, числа, або Unicode-символу + • Містити літери, числа, підкреслення, тире, та Unicode-символи + • Не містити пробілів або спеціальних символів, крім підкреслення і тире`, }; export default UkrainianStrings; diff --git a/chili-and-cilantro-node-lib/src/lib/utils.ts b/chili-and-cilantro-node-lib/src/lib/utils.ts index 7cc0e00..77175a0 100644 --- a/chili-and-cilantro-node-lib/src/lib/utils.ts +++ b/chili-and-cilantro-node-lib/src/lib/utils.ts @@ -161,14 +161,17 @@ export function handleError( ): void { let handleableError: HandleableError; let alreadyHandled = false; + let errorType = 'UnexpectedError'; if (error instanceof HandleableError) { handleableError = error; alreadyHandled = error.handled; + errorType = error.name; } else if (error instanceof Error) { handleableError = new HandleableError(error.message, { cause: error, handled: true, }); + errorType = error.name; } else { handleableError = new HandleableError( (error as any).message ?? translate(StringNames.Common_UnexpectedError), @@ -191,6 +194,7 @@ export function handleError( res.status(handleableError.statusCode).json({ message: handleableError.message, error: handleableError, + errorType: errorType, } as IApiErrorResponse); } handleableError.handled = true; diff --git a/chili-and-cilantro-react/src/app/auth-provider.tsx b/chili-and-cilantro-react/src/app/auth-provider.tsx index b32a89d..e3a8f9a 100644 --- a/chili-and-cilantro-react/src/app/auth-provider.tsx +++ b/chili-and-cilantro-react/src/app/auth-provider.tsx @@ -25,6 +25,7 @@ export interface AuthContextData { isAuthenticated: boolean; loading: boolean; error: string | null; + errorType: string | null; login: ( identifier: string, password: string, @@ -59,6 +60,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [errorType, setErrorType] = useState(null); const [authState, setAuthState] = useState(0); const navigate = useNavigate(); @@ -123,33 +125,27 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { password: string, isEmail: boolean, ): Promise<{ token: string } | { error: string; status?: number }> => { - try { - setLoading(true); - const loginResult = await authService.login( - identifier, - password, - isEmail, - ); - // if loginResult is an object with an error, setError with it - if (typeof loginResult === 'object' && 'error' in loginResult) { - setError(loginResult.error); - } else if (typeof loginResult === 'object' && 'token' in loginResult) { - localStorage.setItem('authToken', loginResult.token); - setAuthState((prev) => prev + 1); - setToken(loginResult.token); - } - return loginResult; - } catch (error: unknown) { - if (error instanceof Error) { - setError(error.message); - setLoading(false); - return { error: error.message }; - } else { - setError('An unknown error occurred'); - setLoading(false); - return { error: 'An unknown error occurred' }; + setLoading(true); + const loginResult = await authService.login( + identifier, + password, + isEmail, + ); + setLoading(false); + // if loginResult is an object with an error, setError with it + if (typeof loginResult === 'object' && 'error' in loginResult) { + setError(loginResult.error); + if ('errorType' in loginResult && loginResult.errorType) { + setErrorType(loginResult.errorType ?? null); } + } else if (typeof loginResult === 'object' && 'token' in loginResult) { + localStorage.setItem('authToken', loginResult.token); + setAuthState((prev) => prev + 1); + setToken(loginResult.token); + setError(null); + setErrorType(null); } + return loginResult; }, [], ); @@ -231,6 +227,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { isAuthenticated, loading, error, + errorType, changePassword, login, logout, @@ -248,6 +245,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { isAuthenticated, loading, error, + errorType, changePassword, login, logout, diff --git a/chili-and-cilantro-react/src/app/components/change-password-page.tsx b/chili-and-cilantro-react/src/app/components/change-password-page.tsx index cb5b133..5d891c7 100644 --- a/chili-and-cilantro-react/src/app/components/change-password-page.tsx +++ b/chili-and-cilantro-react/src/app/components/change-password-page.tsx @@ -16,6 +16,7 @@ import { Navigate } from 'react-router-dom'; import * as Yup from 'yup'; import { AuthContext } from '../auth-provider'; import { useAppTranslation } from '../i18n-provider'; +import MultilineHelperText from './multi-line-helper-text'; const ChangePasswordPage: FC = () => { const { isAuthenticated, user, loading, changePassword } = @@ -28,13 +29,13 @@ const ChangePasswordPage: FC = () => { currentPassword: Yup.string() .matches( constants.PASSWORD_REGEX, - t(StringNames.Validation_PasswordRegexError), + t(StringNames.Validation_PasswordRegexErrorTemplate), ) .required(t(StringNames.Validation_CurrentPasswordRequired)), newPassword: Yup.string() .matches( constants.PASSWORD_REGEX, - t(StringNames.Validation_PasswordRegexError), + t(StringNames.Validation_PasswordRegexErrorTemplate), ) .notOneOf( [Yup.ref('currentPassword')], @@ -127,7 +128,11 @@ const ChangePasswordPage: FC = () => { Boolean(formik.errors.currentPassword) } helperText={ - formik.touched.currentPassword && formik.errors.currentPassword + formik.touched.currentPassword && ( + + ) } /> { error={ formik.touched.newPassword && Boolean(formik.errors.newPassword) } - helperText={formik.touched.newPassword && formik.errors.newPassword} + helperText={ + formik.touched.newPassword && ( + + ) + } /> { const [isTokenValid, setIsTokenValid] = useState(null); const navigate = useNavigate(); const location = useLocation(); + const { t } = useAppTranslation(); useEffect(() => { const params = new URLSearchParams(location.search); const token = params.get('token'); + const validateToken = async (token: string) => { + try { + await api.get(`/user/verify-reset-token?token=${token}`); + setIsTokenValid(true); + } catch { + setIsTokenValid(false); + setErrorMessage(t(StringNames.ForgotPassword_InvalidToken)); + } + }; + if (token) { (async () => { await validateToken(token); })(); } - }, [location]); - - const validateToken = async (token: string) => { - try { - await api.get(`/user/verify-reset-token?token=${token}`); - setIsTokenValid(true); - } catch { - setIsTokenValid(false); - setErrorMessage( - 'Invalid or expired token. Please request a new password reset.', - ); - } - }; + }, [t, location]); const initialValues: FormValues = isTokenValid ? { password: '', confirmPassword: '' } @@ -50,14 +54,19 @@ const ForgotPasswordPage: React.FC = () => { const validationSchema = isTokenValid ? Yup.object({ password: Yup.string() - .matches(constants.PASSWORD_REGEX, constants.PASSWORD_REGEX_ERROR) - .required('Required'), + .matches( + constants.PASSWORD_REGEX, + t(StringNames.Validation_PasswordRegexErrorTemplate), + ) + .required(t(StringNames.Validation_Required)), confirmPassword: Yup.string() - .oneOf([Yup.ref('password')], 'Passwords must match') - .required('Required'), + .oneOf([Yup.ref('password')], t(StringNames.Validation_PasswordMatch)) + .required(t(StringNames.Validation_Required)), }) : Yup.object({ - email: Yup.string().email('Invalid email address').required('Required'), + email: Yup.string() + .email(t(StringNames.Validation_InvalidEmail)) + .required(t(StringNames.Validation_Required)), }); const formik = useFormik({ @@ -82,9 +91,7 @@ const ForgotPasswordPage: React.FC = () => { const params = new URLSearchParams(location.search); const token = params.get('token'); if (!token) { - setErrorMessage( - 'Invalid token. Please try the password reset process again.', - ); + setErrorMessage(t(StringNames.ForgotPassword_InvalidToken)); return; } const response = await api.post('/user/reset-password', { @@ -92,9 +99,7 @@ const ForgotPasswordPage: React.FC = () => { password: values.password, }); if (response.status === 200) { - setSuccessMessage( - 'Your password has been successfully reset. You can now log in with your new password.', - ); + setSuccessMessage(t(StringNames.ForgotPassword_Success)); setErrorMessage(''); setTimeout(() => navigate('/login'), 3000); } else { @@ -106,11 +111,11 @@ const ForgotPasswordPage: React.FC = () => { if (isAxiosError(error) && error.response) { setErrorMessage( error.response.data.message || - 'An error occurred while processing your request.', + t(StringNames.Common_UnexpectedError), ); setSuccessMessage(''); } else { - setErrorMessage('An unexpected error occurred'); + setErrorMessage(t(StringNames.Common_UnexpectedError)); setSuccessMessage(''); } } @@ -128,7 +133,9 @@ const ForgotPasswordPage: React.FC = () => { }} > - {isTokenValid ? 'Reset Password' : 'Forgot Password'} + {isTokenValid + ? t(StringNames.ForgotPassword_ResetPassword) + : t(StringNames.ForgotPassword_ForgotPassword)} { required fullWidth name="password" - label="New Password" + label={t(StringNames.Common_NewPassword)} type="password" id="password" autoComplete="new-password" @@ -153,14 +160,20 @@ const ForgotPasswordPage: React.FC = () => { error={ formik.touched.password && Boolean(formik.errors.password) } - helperText={formik.touched.password && formik.errors.password} + helperText={ + formik.touched.password && ( + + ) + } /> { required fullWidth id="email" - label="Email Address" + label={t(StringNames.Common_Email)} name="email" autoComplete="email" autoFocus @@ -200,7 +213,9 @@ const ForgotPasswordPage: React.FC = () => { variant="contained" sx={{ mt: 3, mb: 2 }} > - {isTokenValid ? 'Reset Password' : 'Send Reset Email'} + {isTokenValid + ? t(StringNames.ForgotPassword_ResetPassword) + : t(StringNames.ForgotPassword_SendResetToken)} {successMessage && ( diff --git a/chili-and-cilantro-react/src/app/components/login-page.tsx b/chili-and-cilantro-react/src/app/components/login-page.tsx index 2771ba6..9f57e2e 100644 --- a/chili-and-cilantro-react/src/app/components/login-page.tsx +++ b/chili-and-cilantro-react/src/app/components/login-page.tsx @@ -1,4 +1,7 @@ -import { constants } from '@chili-and-cilantro/chili-and-cilantro-lib'; +import { + constants, + StringNames, +} from '@chili-and-cilantro/chili-and-cilantro-lib'; import { Box, Button, @@ -7,12 +10,15 @@ import { TextField, Typography, } from '@mui/material'; -import axios from 'axios'; +import { isAxiosError } from 'axios'; import { useFormik } from 'formik'; import { useEffect, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import * as Yup from 'yup'; import { useAuth } from '../auth-provider'; +import { useAppTranslation } from '../i18n-provider'; +import api from '../services/api'; +import MultilineHelperText from './multi-line-helper-text'; interface FormValues { email: string; @@ -25,7 +31,8 @@ const LoginPage = () => { const [loginError, setLoginError] = useState(null); const [resendStatus, setResendStatus] = useState(null); const navigate = useNavigate(); - const { login } = useAuth(); + const { login, errorType } = useAuth(); + const { t } = useAppTranslation(); const formik = useFormik({ initialValues: { @@ -36,13 +43,21 @@ const LoginPage = () => { validationSchema: Yup.object({ [loginType]: loginType === 'email' - ? Yup.string().email('Invalid email address').required('Required') + ? Yup.string() + .email(t(StringNames.Validation_InvalidEmail)) + .required(t(StringNames.Validation_Required)) : Yup.string() - .matches(constants.USERNAME_REGEX, constants.USERNAME_REGEX_ERROR) - .required('Required'), + .matches( + constants.USERNAME_REGEX, + t(StringNames.Validation_UsernameRegexErrorTemplate), + ) + .required(t(StringNames.Validation_Required)), password: Yup.string() - .matches(constants.PASSWORD_REGEX, constants.PASSWORD_REGEX_ERROR) - .required('Required'), + .matches( + constants.PASSWORD_REGEX, + t(StringNames.Validation_PasswordRegexErrorTemplate), + ) + .required(t(StringNames.Validation_Required)), }), onSubmit: async (values, { setSubmitting, resetForm }) => { try { @@ -52,13 +67,14 @@ const LoginPage = () => { loginType === 'email', ); if ('error' in loginResult) { + console.error(loginResult); setLoginError(loginResult.error); return; } resetForm(); navigate('/dashboard'); } catch (error) { - setLoginError('An unexpected error occurred'); + setLoginError(t(StringNames.Common_UnexpectedError)); } finally { setSubmitting(false); } @@ -67,15 +83,15 @@ const LoginPage = () => { const handleResendVerification = async () => { try { - await axios.post('/user/resend-verification', { + await api.post('/user/resend-verification', { [loginType]: formik.values[loginType], }); - setResendStatus('Verification email sent successfully'); + setResendStatus(t(StringNames.Login_ResentPasswordSuccess)); } catch (error) { - if (axios.isAxiosError(error) && error.response) { + if (isAxiosError(error) && error.response) { setResendStatus(error.response.data.message); } else { - setResendStatus('Failed to resend verification email'); + setResendStatus(t(StringNames.Login_ResentPasswordFailure)); } } }; @@ -100,7 +116,7 @@ const LoginPage = () => { }} > - Login + {t(StringNames.Login_Title)} { required fullWidth id={loginType} - label={loginType === 'email' ? 'Email Address' : 'Username'} + label={ + loginType === 'email' + ? t(StringNames.Common_Email) + : t(StringNames.Common_Username) + } name={loginType} autoComplete={loginType === 'email' ? 'email' : 'username'} autoFocus @@ -122,21 +142,31 @@ const LoginPage = () => { error={ formik.touched[loginType] && Boolean(formik.errors[loginType]) } - helperText={formik.touched[loginType] && formik.errors[loginType]} + helperText={ + formik.touched[loginType] && ( + + ) + } /> + ) + } /> {loginError && ( @@ -156,9 +186,11 @@ const LoginPage = () => { sx={{ mt: 3, mb: 2 }} disabled={formik.isSubmitting} > - {formik.isSubmitting ? 'Logging in...' : 'Login'} + {formik.isSubmitting + ? t(StringNames.Login_Progress) + : t(StringNames.Login_LoginButton)} - {loginError === 'Account status is PendingEmailVerification' && ( + {errorType === 'PendingEmailVerification' && (