diff --git a/migrations/1760537448__add_locale_to_admin_users.surql b/migrations/1760537448__add_locale_to_admin_users.surql new file mode 100644 index 00000000..5a6ec0dd --- /dev/null +++ b/migrations/1760537448__add_locale_to_admin_users.surql @@ -0,0 +1,3 @@ +-- Add locale field to admin_users table +-- This allows each admin user to set their preferred language/locale +DEFINE FIELD locale ON TABLE admin_users TYPE string DEFAULT "en"; diff --git a/proto/admin_user.proto b/proto/admin_user.proto index 67ba7fbf..60085f29 100644 --- a/proto/admin_user.proto +++ b/proto/admin_user.proto @@ -16,6 +16,7 @@ message AdminUserModel { string created_by = 8; string updated_by = 9; repeated RoleModel roles = 10; + string locale = 11; } @@ -65,6 +66,7 @@ message StoreAdminUserRequest { bool is_super_admin = 5; bytes profile_image_content = 6; string profile_image_file_name = 7; + string locale = 8; } message StoreAdminUserResponse { @@ -91,6 +93,7 @@ message UpdateAdminUserRequest { string profile_image_file_name = 4; repeated string role_ids = 5; bool is_super_admin = 6; + string locale = 7; } message UpdateAdminUserResponse { diff --git a/src/api/admin_user_api.rs b/src/api/admin_user_api.rs index d1a5b1a7..660f0b4a 100644 --- a/src/api/admin_user_api.rs +++ b/src/api/admin_user_api.rs @@ -97,6 +97,7 @@ impl AdminUser for AdminUserApi { let request_data = request.into_inner(); request_data.validate(&self.state).await?; + match self .state .admin_user_service diff --git a/src/api/misc_api.rs b/src/api/misc_api.rs index 2b1fecbb..9c3a4adc 100644 --- a/src/api/misc_api.rs +++ b/src/api/misc_api.rs @@ -386,6 +386,7 @@ impl Misc for MiscApi { is_super_admin: false, profile_image_content: vec![], profile_image_file_name: String::new(), + locale: String::from("en"), }; let created_admin_user = self diff --git a/src/api/proto/admin_user.rs b/src/api/proto/admin_user.rs index 6afd2e03..d6c18e71 100644 --- a/src/api/proto/admin_user.rs +++ b/src/api/proto/admin_user.rs @@ -21,6 +21,8 @@ pub struct AdminUserModel { pub updated_by: ::prost::alloc::string::String, #[prost(message, repeated, tag = "10")] pub roles: ::prost::alloc::vec::Vec, + #[prost(string, tag = "11")] + pub locale: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RoleModel { @@ -96,6 +98,8 @@ pub struct StoreAdminUserRequest { pub profile_image_content: ::prost::alloc::vec::Vec, #[prost(string, tag = "7")] pub profile_image_file_name: ::prost::alloc::string::String, + #[prost(string, tag = "8")] + pub locale: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct StoreAdminUserResponse { @@ -130,6 +134,8 @@ pub struct UpdateAdminUserRequest { pub role_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(bool, tag = "6")] pub is_super_admin: bool, + #[prost(string, tag = "7")] + pub locale: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateAdminUserResponse { diff --git a/src/models/admin_user_model.rs b/src/models/admin_user_model.rs index 70bb631a..bf641e5a 100644 --- a/src/models/admin_user_model.rs +++ b/src/models/admin_user_model.rs @@ -46,6 +46,9 @@ pub struct AdminUserModel { /// The roles assigned to the admin user. pub roles: Vec, + + /// The preferred locale/language for the admin user. + pub locale: String, } // region: impl try_from AdminUserModel @@ -99,6 +102,7 @@ impl TryFrom for GrpcAdminUserModel { created_by: val.created_by, updated_by: val.updated_by, roles: grpc_roles, + locale: val.locale, }; Ok(model) @@ -148,6 +152,7 @@ impl TryFrom for AdminUserModel { let updated_at = val.get("updated_at").get_datetime()?; let created_by = val.get("created_by").get_string()?; let updated_by = val.get("updated_by").get_string()?; + let locale = val.get("locale").get_string().unwrap_or_else(|_| String::from("en")); Ok(Self { id, @@ -161,6 +166,7 @@ impl TryFrom for AdminUserModel { created_by, updated_by, roles, + locale, }) } } @@ -188,6 +194,9 @@ pub struct CreatableAdminUserModel { /// The username of the user who is creating this admin user. pub logged_in_username: String, + + /// The preferred locale/language for the admin user. + pub locale: String, // pub role_ids: Vec, } @@ -202,7 +211,7 @@ pub struct UpdatableAdminUserModel { /// The email address of the admin user. pub profile_image: String, - + /// is the admin user has super admin privileges. pub is_super_admin: bool, @@ -211,6 +220,9 @@ pub struct UpdatableAdminUserModel { /// The roles assigned to the admin user. pub role_ids: Vec, + + /// The preferred locale/language for the admin user. + pub locale: String, } // /// Represents a paginated response for admin users. diff --git a/src/repositories/admin_user_repository.rs b/src/repositories/admin_user_repository.rs index e3fc0deb..3b732a48 100644 --- a/src/repositories/admin_user_repository.rs +++ b/src/repositories/admin_user_repository.rs @@ -122,6 +122,10 @@ impl AdminUserRepository { "is_super_admin".into(), creatable_admin_user_model.is_super_admin.into(), ), + ( + "locale".into(), + creatable_admin_user_model.locale.into(), + ), ( "created_by".into(), creatable_admin_user_model.logged_in_username.clone().into(), @@ -160,6 +164,7 @@ impl AdminUserRepository { full_name: $full_name, profile_image: $profile_image, is_super_admin: $is_super_admin, + locale: $locale, updated_by: $logged_in_user_name, updated_at: time::now() };"; @@ -178,6 +183,7 @@ impl AdminUserRepository { "is_super_admin".into(), updatable_admin_user.is_super_admin.into(), ), + ("locale".into(), updatable_admin_user.locale.into()), ("id".into(), updatable_admin_user.id.into()), ("table".into(), ADMIN_USER_TABLE.into()), ]); diff --git a/src/requests/admin_user_request/store_admin_user_request.rs b/src/requests/admin_user_request/store_admin_user_request.rs index 0cc98f92..29361f62 100644 --- a/src/requests/admin_user_request/store_admin_user_request.rs +++ b/src/requests/admin_user_request/store_admin_user_request.rs @@ -2,6 +2,7 @@ use crate::api::proto::admin_user::StoreAdminUserRequest; use crate::avored_state::AvoRedState; use crate::models::validation_error::{ErrorMessage, ErrorResponse, Validate}; use rust_i18n::t; +use std::path::Path; impl StoreAdminUserRequest { /// validate @@ -63,6 +64,20 @@ impl StoreAdminUserRequest { errors.push(error_message); } + // Validate locale if provided (strict validation) + if !self.locale.is_empty() { + let locale_path = Path::new("resources/locales").join(format!("{}.json", self.locale)); + if !locale_path.exists() { + let error_message = ErrorMessage { + key: String::from("locale"), + message: t!("validation_invalid", attribute = t!("locale")).to_string(), + }; + + valid = false; + errors.push(error_message); + } + } + if !valid { let error_response = ErrorResponse { status: valid, diff --git a/src/requests/admin_user_request/update_admin_user_request.rs b/src/requests/admin_user_request/update_admin_user_request.rs index 2f27e330..63a55296 100644 --- a/src/requests/admin_user_request/update_admin_user_request.rs +++ b/src/requests/admin_user_request/update_admin_user_request.rs @@ -1,6 +1,7 @@ use crate::api::proto::admin_user::UpdateAdminUserRequest; use crate::models::validation_error::{ErrorMessage, ErrorResponse, Validate}; use rust_i18n::t; +use std::path::Path; impl UpdateAdminUserRequest { /// validate @@ -18,6 +19,20 @@ impl UpdateAdminUserRequest { errors.push(error_message); } + // Validate locale if provided (strict validation) + if !self.locale.is_empty() { + let locale_path = Path::new("resources/locales").join(format!("{}.json", self.locale)); + if !locale_path.exists() { + let error_message = ErrorMessage { + key: String::from("locale"), + message: t!("validation_invalid", attribute = t!("locale")).to_string(), + }; + + valid = false; + errors.push(error_message); + } + } + if !valid { let error_response = ErrorResponse { status: valid, diff --git a/src/services/admin_user_service.rs b/src/services/admin_user_service.rs index a67d7568..27805248 100644 --- a/src/services/admin_user_service.rs +++ b/src/services/admin_user_service.rs @@ -94,6 +94,14 @@ impl AdminUserService { let password_hash = self.get_password_hash_from_raw_password(&req.password, password_salt)?; + // Use provided locale or default to "en" if empty + // Request layer already validated that locale exists + let locale = if req.locale.is_empty() { + String::from("en") + } else { + req.locale.clone() + }; + let mut created_admin_user_model = CreatableAdminUserModel { full_name: req.full_name, email: req.email, @@ -101,6 +109,7 @@ impl AdminUserService { profile_image: String::new(), is_super_admin: req.is_super_admin, logged_in_username, + locale, }; if !req.profile_image_file_name.is_empty() { @@ -153,6 +162,15 @@ impl AdminUserService { logged_in_username: String, (datastore, database_session): &DB, ) -> Result { + + // Use provided locale or default to "en" if empty + // Request layer already validated that locale exists + let locale = if req.locale.is_empty() { + String::from("en") + } else { + req.locale.clone() + }; + let mut updatable_admin_user_model = UpdatableAdminUserModel { id: req.admin_user_id, full_name: req.full_name, @@ -160,6 +178,7 @@ impl AdminUserService { is_super_admin: req.is_super_admin, logged_in_username: logged_in_username.clone(), role_ids: req.role_ids, + locale, }; if !req.profile_image_file_name.is_empty() { diff --git a/src/services/ldap_auth_service.rs b/src/services/ldap_auth_service.rs index 1b7e2324..459bd064 100644 --- a/src/services/ldap_auth_service.rs +++ b/src/services/ldap_auth_service.rs @@ -211,6 +211,7 @@ impl LdapAuthService { profile_image: String::new(), is_super_admin: false, // LDAP users are not super admins by default logged_in_username: "system".to_string(), + locale: String::from("en"), }; self.admin_user_repository diff --git a/ts-grpc-vite-admin/package-lock.json b/ts-grpc-vite-admin/package-lock.json index 5e58c113..2fe4aac1 100644 --- a/ts-grpc-vite-admin/package-lock.json +++ b/ts-grpc-vite-admin/package-lock.json @@ -47,6 +47,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "google-protobuf": "^4.0.0", "typescript": "~5.8.3", "typescript-eslint": "^8.43.0", "vite": "^7.1.6", diff --git a/ts-grpc-vite-admin/package.json b/ts-grpc-vite-admin/package.json index a25e2884..d82499b0 100644 --- a/ts-grpc-vite-admin/package.json +++ b/ts-grpc-vite-admin/package.json @@ -49,6 +49,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "google-protobuf": "^4.0.0", "typescript": "~5.8.3", "typescript-eslint": "^8.43.0", "vite": "^7.1.6", diff --git a/ts-grpc-vite-admin/src/components/SelectField.tsx b/ts-grpc-vite-admin/src/components/SelectField.tsx new file mode 100644 index 00000000..5fb889ab --- /dev/null +++ b/ts-grpc-vite-admin/src/components/SelectField.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; + +type SelectFieldProps = { + name: string; + label: string; + id?: string; + required?: boolean; + value?: string; + onChange?: (e: React.ChangeEvent, ...args: any[]) => void; + disabled?: boolean; + register: any; + children: React.ReactNode; +} + +const SelectField = (props: SelectFieldProps) => { + return ( +
+ + +
+ +
+ +
+
+
+ ); +}; + +export default SelectField; diff --git a/ts-grpc-vite-admin/src/grpc-avored/Admin_userServiceClientPb.ts b/ts-grpc-vite-admin/src/grpc-avored/Admin_userServiceClientPb.ts index 3f9d3e2d..b406388f 100644 --- a/ts-grpc-vite-admin/src/grpc-avored/Admin_userServiceClientPb.ts +++ b/ts-grpc-vite-admin/src/grpc-avored/Admin_userServiceClientPb.ts @@ -7,7 +7,7 @@ // Code generated by protoc-gen-grpc-web. DO NOT EDIT. // versions: // protoc-gen-grpc-web v1.5.0 -// protoc v6.32.0 +// protoc v4.25.1 // source: admin_user.proto diff --git a/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.d.ts b/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.d.ts index ba06f5d1..2a20c9c3 100644 --- a/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.d.ts +++ b/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.d.ts @@ -40,6 +40,9 @@ export class AdminUserModel extends jspb.Message { clearRolesList(): AdminUserModel; addRoles(value?: RoleModel, index?: number): RoleModel; + getLocale(): string; + setLocale(value: string): AdminUserModel; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): AdminUserModel.AsObject; static toObject(includeInstance: boolean, msg: AdminUserModel): AdminUserModel.AsObject; @@ -60,6 +63,7 @@ export namespace AdminUserModel { createdBy: string, updatedBy: string, rolesList: Array, + locale: string, } } @@ -267,6 +271,9 @@ export class StoreAdminUserRequest extends jspb.Message { getProfileImageFileName(): string; setProfileImageFileName(value: string): StoreAdminUserRequest; + getLocale(): string; + setLocale(value: string): StoreAdminUserRequest; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): StoreAdminUserRequest.AsObject; static toObject(includeInstance: boolean, msg: StoreAdminUserRequest): StoreAdminUserRequest.AsObject; @@ -284,6 +291,7 @@ export namespace StoreAdminUserRequest { isSuperAdmin: boolean, profileImageContent: Uint8Array | string, profileImageFileName: string, + locale: string, } } @@ -376,6 +384,9 @@ export class UpdateAdminUserRequest extends jspb.Message { getIsSuperAdmin(): boolean; setIsSuperAdmin(value: boolean): UpdateAdminUserRequest; + getLocale(): string; + setLocale(value: string): UpdateAdminUserRequest; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): UpdateAdminUserRequest.AsObject; static toObject(includeInstance: boolean, msg: UpdateAdminUserRequest): UpdateAdminUserRequest.AsObject; @@ -392,6 +403,7 @@ export namespace UpdateAdminUserRequest { profileImageFileName: string, roleIdsList: Array, isSuperAdmin: boolean, + locale: string, } } diff --git a/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.js b/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.js index e04b0d87..7893bf82 100644 --- a/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.js +++ b/ts-grpc-vite-admin/src/grpc-avored/admin_user_pb.js @@ -798,7 +798,8 @@ updatedAt: (f = msg.getUpdatedAt()) && google_protobuf_timestamp_pb.Timestamp.to createdBy: jspb.Message.getFieldWithDefault(msg, 8, ""), updatedBy: jspb.Message.getFieldWithDefault(msg, 9, ""), rolesList: jspb.Message.toObjectList(msg.getRolesList(), - proto.admin_user.RoleModel.toObject, includeInstance) + proto.admin_user.RoleModel.toObject, includeInstance), +locale: jspb.Message.getFieldWithDefault(msg, 11, "") }; if (includeInstance) { @@ -878,6 +879,10 @@ proto.admin_user.AdminUserModel.deserializeBinaryFromReader = function(msg, read reader.readMessage(value,proto.admin_user.RoleModel.deserializeBinaryFromReader); msg.addRoles(value); break; + case 11: + var value = /** @type {string} */ (reader.readString()); + msg.setLocale(value); + break; default: reader.skipField(); break; @@ -980,6 +985,13 @@ proto.admin_user.AdminUserModel.serializeBinaryToWriter = function(message, writ proto.admin_user.RoleModel.serializeBinaryToWriter ); } + f = message.getLocale(); + if (f.length > 0) { + writer.writeString( + 11, + f + ); + } }; @@ -1221,6 +1233,24 @@ proto.admin_user.AdminUserModel.prototype.clearRolesList = function() { }; +/** + * optional string locale = 11; + * @return {string} + */ +proto.admin_user.AdminUserModel.prototype.getLocale = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 11, "")); +}; + + +/** + * @param {string} value + * @return {!proto.admin_user.AdminUserModel} returns this + */ +proto.admin_user.AdminUserModel.prototype.setLocale = function(value) { + return jspb.Message.setProto3StringField(this, 11, value); +}; + + /** * List of repeated fields within this message type. @@ -2545,7 +2575,8 @@ password: jspb.Message.getFieldWithDefault(msg, 3, ""), confirmPassword: jspb.Message.getFieldWithDefault(msg, 4, ""), isSuperAdmin: jspb.Message.getBooleanFieldWithDefault(msg, 5, false), profileImageContent: msg.getProfileImageContent_asB64(), -profileImageFileName: jspb.Message.getFieldWithDefault(msg, 7, "") +profileImageFileName: jspb.Message.getFieldWithDefault(msg, 7, ""), +locale: jspb.Message.getFieldWithDefault(msg, 8, "") }; if (includeInstance) { @@ -2610,6 +2641,10 @@ proto.admin_user.StoreAdminUserRequest.deserializeBinaryFromReader = function(ms var value = /** @type {string} */ (reader.readString()); msg.setProfileImageFileName(value); break; + case 8: + var value = /** @type {string} */ (reader.readString()); + msg.setLocale(value); + break; default: reader.skipField(); break; @@ -2688,6 +2723,13 @@ proto.admin_user.StoreAdminUserRequest.serializeBinaryToWriter = function(messag f ); } + f = message.getLocale(); + if (f.length > 0) { + writer.writeString( + 8, + f + ); + } }; @@ -2841,6 +2883,24 @@ proto.admin_user.StoreAdminUserRequest.prototype.setProfileImageFileName = funct }; +/** + * optional string locale = 8; + * @return {string} + */ +proto.admin_user.StoreAdminUserRequest.prototype.getLocale = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 8, "")); +}; + + +/** + * @param {string} value + * @return {!proto.admin_user.StoreAdminUserRequest} returns this + */ +proto.admin_user.StoreAdminUserRequest.prototype.setLocale = function(value) { + return jspb.Message.setProto3StringField(this, 8, value); +}; + + @@ -3377,7 +3437,8 @@ fullName: jspb.Message.getFieldWithDefault(msg, 2, ""), profileImageContent: msg.getProfileImageContent_asB64(), profileImageFileName: jspb.Message.getFieldWithDefault(msg, 4, ""), roleIdsList: (f = jspb.Message.getRepeatedField(msg, 5)) == null ? undefined : f, -isSuperAdmin: jspb.Message.getBooleanFieldWithDefault(msg, 6, false) +isSuperAdmin: jspb.Message.getBooleanFieldWithDefault(msg, 6, false), +locale: jspb.Message.getFieldWithDefault(msg, 7, "") }; if (includeInstance) { @@ -3438,6 +3499,10 @@ proto.admin_user.UpdateAdminUserRequest.deserializeBinaryFromReader = function(m var value = /** @type {boolean} */ (reader.readBool()); msg.setIsSuperAdmin(value); break; + case 7: + var value = /** @type {string} */ (reader.readString()); + msg.setLocale(value); + break; default: reader.skipField(); break; @@ -3509,6 +3574,13 @@ proto.admin_user.UpdateAdminUserRequest.serializeBinaryToWriter = function(messa f ); } + f = message.getLocale(); + if (f.length > 0) { + writer.writeString( + 7, + f + ); + } }; @@ -3663,6 +3735,24 @@ proto.admin_user.UpdateAdminUserRequest.prototype.setIsSuperAdmin = function(val }; +/** + * optional string locale = 7; + * @return {string} + */ +proto.admin_user.UpdateAdminUserRequest.prototype.getLocale = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 7, "")); +}; + + +/** + * @param {string} value + * @return {!proto.admin_user.UpdateAdminUserRequest} returns this + */ +proto.admin_user.UpdateAdminUserRequest.prototype.setLocale = function(value) { + return jspb.Message.setProto3StringField(this, 7, value); +}; + + diff --git a/ts-grpc-vite-admin/src/pages/admin_user/AdminUserCreatePage.tsx b/ts-grpc-vite-admin/src/pages/admin_user/AdminUserCreatePage.tsx index 58c42053..199cfec6 100644 --- a/ts-grpc-vite-admin/src/pages/admin_user/AdminUserCreatePage.tsx +++ b/ts-grpc-vite-admin/src/pages/admin_user/AdminUserCreatePage.tsx @@ -1,4 +1,5 @@ import InputField from "../../components/InputField"; +import SelectField from "../../components/SelectField"; import {useTranslation} from "react-i18next"; import ErrorMessage from "../../components/ErrorMessage"; import {Link} from "react-router-dom"; @@ -47,6 +48,7 @@ export const AdminUserCreatePage = () => { store_admin_user.setPassword(data.password); store_admin_user.setConfirmPassword(data.confirmation_password) store_admin_user.setIsSuperAdmin(false) + store_admin_user.setLocale(data.locale || "en") store_admin_user.setProfileImageFileName(profile_image_file_name) @@ -59,6 +61,7 @@ export const AdminUserCreatePage = () => { store_admin_user.setPassword(data.password); store_admin_user.setConfirmPassword(data.confirmation_password) store_admin_user.setIsSuperAdmin(false) + store_admin_user.setLocale(data.locale || "en") mutate(store_admin_user) } @@ -113,6 +116,18 @@ export const AdminUserCreatePage = () => { +
+ + + + + +
+ {/* { const values: EditAdminUserType = data?.data as unknown as EditAdminUserType; const admin_user_role_list = data?.data?.rolesList ?? []; + if (values) { + values.locale = values.locale || "en"; values.roles = admin_user_role_list as Array as RoleType[]; values.roles = _.uniqBy(values.roles, 'id') @@ -93,6 +96,7 @@ export const AdminUserEditPage = () => { update_admin_user.setAdminUserId(params.admin_user_id ?? ''); update_admin_user.setRoleIdsList(selectedOption); update_admin_user.setIsSuperAdmin(data.isSuperAdmin) + update_admin_user.setLocale(data.locale || "en") var profile_image_file_name = "" const file: File = data.profile_image[0]; @@ -147,6 +151,18 @@ export const AdminUserEditPage = () => { /> +
+ + + + + +
+ ; isSuperAdmin: boolean; + locale: string; } export type ChangePasswordType = {