diff --git a/README.md b/README.md index ab4ed3b98e51..3c659de34e49 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,13 @@ brew install protobuf apt-get install protobuf-compiler libopenblas-dev ``` -3. Now, you can build Tabby by running the command `cargo build`. +3. Install useful tools: +```bash +# For Ubuntu +sudo apt install make sqlite3 graphviz +``` + +4. Now, you can build Tabby by running the command `cargo build`. ### Start Hacking! ... and don't forget to submit a [Pull Request](https://github.com/TabbyML/tabby/compare) diff --git a/ee/tabby-db/migrations/0030_user-name.down.sql b/ee/tabby-db/migrations/0030_user-name.down.sql new file mode 100644 index 000000000000..1592aac27486 --- /dev/null +++ b/ee/tabby-db/migrations/0030_user-name.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN name; diff --git a/ee/tabby-db/migrations/0030_user-name.up.sql b/ee/tabby-db/migrations/0030_user-name.up.sql new file mode 100644 index 000000000000..6bc54f4d339d --- /dev/null +++ b/ee/tabby-db/migrations/0030_user-name.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN name VARCHAR(255); diff --git a/ee/tabby-db/schema.sqlite b/ee/tabby-db/schema.sqlite index 4d5fbbb52d80..66f4681b8d7b 100644 Binary files a/ee/tabby-db/schema.sqlite and b/ee/tabby-db/schema.sqlite differ diff --git a/ee/tabby-db/schema/schema.sql b/ee/tabby-db/schema/schema.sql index cd1904ffcd34..61ace098e575 100644 --- a/ee/tabby-db/schema/schema.sql +++ b/ee/tabby-db/schema/schema.sql @@ -24,6 +24,7 @@ CREATE TABLE users( active BOOLEAN NOT NULL DEFAULT 1, password_encrypted VARCHAR(128), avatar BLOB DEFAULT NULL, + name VARCHAR(255), CONSTRAINT `idx_email` UNIQUE(`email`) CONSTRAINT `idx_auth_token` UNIQUE(`auth_token`) ); diff --git a/ee/tabby-db/schema/schema.svg b/ee/tabby-db/schema/schema.svg index 37fb06b985e1..b554dd2aa1ba 100644 --- a/ee/tabby-db/schema/schema.svg +++ b/ee/tabby-db/schema/schema.svg @@ -392,6 +392,10 @@   avatar + +  + +name diff --git a/ee/tabby-db/src/users.rs b/ee/tabby-db/src/users.rs index e31960110b54..3a02cbdb99e8 100644 --- a/ee/tabby-db/src/users.rs +++ b/ee/tabby-db/src/users.rs @@ -14,6 +14,7 @@ pub struct UserDAO { pub id: i64, pub email: String, + pub name: Option, pub password_encrypted: Option, pub is_admin: bool, @@ -28,7 +29,7 @@ macro_rules! select { ($str:literal $(,)? $($val:expr),*) => { query_as!( UserDAO, - r#"SELECT id as "id!", email, password_encrypted, is_admin, created_at as "created_at!", updated_at as "updated_at!", auth_token, active FROM users WHERE "# + $str, + r#"SELECT id as "id!", email, name, password_encrypted, is_admin, created_at as "created_at!", updated_at as "updated_at!", auth_token, active FROM users WHERE "# + $str, $($val),* ) } @@ -123,6 +124,7 @@ impl DbConn { [ "id"!, "email", + "name", "password_encrypted", "is_admin", "created_at"!, @@ -256,6 +258,13 @@ impl DbConn { }) .await } + + pub async fn update_user_name(&self, id: i64, name: String) -> Result<()> { + query!("UPDATE users SET name = ? WHERE id = ?;", name, id) + .execute(&self.pool) + .await?; + Ok(()) + } } fn generate_auth_token() -> String { @@ -292,6 +301,20 @@ mod tests { assert!(conn.update_user_active(id, false).await.is_err()); } + #[tokio::test] + async fn test_update_user_name() { + let conn = DbConn::new_in_memory().await.unwrap(); + let id = create_user(&conn).await; + + let user = conn.get_user(id).await.unwrap().unwrap(); + assert_eq!(user.name, None); + + conn.update_user_name(id, "test".into()).await.unwrap(); + + let user = conn.get_user(id).await.unwrap().unwrap(); + assert_eq!(user.name, Some("test".into())); + } + #[tokio::test] async fn test_get_user_by_email() { let conn = DbConn::new_in_memory().await.unwrap(); diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index a5e237cd893b..821ef202a7bc 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -322,6 +322,7 @@ type Mutation { updateUserActive(id: ID!, active: Boolean!): Boolean! updateUserRole(id: ID!, isAdmin: Boolean!): Boolean! uploadUserAvatarBase64(id: ID!, avatarBase64: String): Boolean! + updateUserName(id: ID!, name: String!): Boolean! register(email: String!, password1: String!, password2: String!, invitationCode: String): RegisterResponse! tokenAuth(email: String!, password: String!): TokenAuthResponse! verifyToken(token: String!): Boolean! @@ -445,6 +446,7 @@ type TokenAuthResponse { type User { id: ID! email: String! + name: String! isAdmin: Boolean! isOwner: Boolean! authToken: String! diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index eaefdfe96154..93fa9b283156 100644 --- a/ee/tabby-schema/src/dao.rs +++ b/ee/tabby-schema/src/dao.rs @@ -52,6 +52,7 @@ impl From for auth::User { auth::User { id: val.id.as_id(), email: val.email, + name: val.name.unwrap_or_default(), is_owner, is_admin: val.is_admin, auth_token: val.auth_token, diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index d827dbec5a0d..6abc06e2adff 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -175,6 +175,7 @@ impl JWTPayload { pub struct User { pub id: juniper::ID, pub email: String, + pub name: String, pub is_admin: bool, pub is_owner: bool, pub auth_token: String, @@ -278,6 +279,22 @@ pub struct PasswordChangeInput { pub new_password2: String, } +#[derive(Validate)] +pub struct UpdateUserNameInput { + #[validate(length(min = 2, code = "name", message = "Name must be at least 2 characters"))] + #[validate(length( + max = 20, + code = "name", + message = "Name must be at most 20 characters" + ))] + #[validate(regex( + code = "name", + path = "crate::schema::constants::USERNAME_REGEX", + message = "Invalid name, name may contain numbers or special characters which are not supported" + ))] + pub name: String, +} + #[derive(Debug, Serialize, Deserialize, GraphQLObject)] #[graphql(context = Context)] pub struct Invitation { @@ -406,6 +423,7 @@ pub trait AuthenticationService: Send + Sync { async fn update_user_role(&self, id: &ID, is_admin: bool) -> Result<()>; async fn update_user_avatar(&self, id: &ID, avatar: Option>) -> Result<()>; async fn get_user_avatar(&self, id: &ID) -> Result>>; + async fn update_user_name(&self, id: &ID, name: String) -> Result<()>; } fn validate_password(value: &str) -> Result<(), validator::ValidationError> { diff --git a/ee/tabby-schema/src/schema/constants.rs b/ee/tabby-schema/src/schema/constants.rs index 7a0cffcc91a3..66270f823e28 100644 --- a/ee/tabby-schema/src/schema/constants.rs +++ b/ee/tabby-schema/src/schema/constants.rs @@ -3,4 +3,41 @@ use regex::Regex; lazy_static! { pub static ref REPOSITORY_NAME_REGEX: Regex = Regex::new("^[a-zA-Z][\\w.-]+$").unwrap(); + pub static ref USERNAME_REGEX: Regex = + Regex::new(r"^[^0-9±!@£$%^&*_+§¡€#¢¶•ªº«\\/<>?:;|=.,]{2,20}$").unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_username_regex() { + let test_cases = vec![ + ("John", true), // English name + ("Müller", true), // German name + ("Jørgensen", true), // Danish name + ("李雷", true), // Chinese name + ("あきは", true), // Japanese name + ("김민수", true), // Korean name + ("Алексей", true), // Russian name + ("José", true), // Spanish names + ("علی", true), // Iranian names + // Edge cases + ("", false), // Empty string + ("JohnDoeIsAReallyLongName", false), // More than 20 characters + ("John!", false), // Invalid character '!' + ("José@", false), // Invalid character '@' + ("12345", false), // Invalid character Numbers + ("John_Doe", false), // Underscore character + ("Anna-Marie", true), // Hyphen character + ("O'Connor", true), // Apostrophe + ("李@伟", false), + ]; + + for (name, expected) in test_cases { + let result = USERNAME_REGEX.is_match(name); + assert_eq!(result, expected, "Failed for name: {}", name); + } + } } diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index c86910fbc8dd..dec90a13bdd7 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -616,6 +616,19 @@ impl Mutation { Ok(true) } + async fn update_user_name(ctx: &Context, id: ID, name: String) -> Result { + let claims = check_claims(ctx)?; + if claims.sub != id { + return Err(CoreError::Unauthorized( + "You cannot change another user's name", + )); + } + let input = auth::UpdateUserNameInput { name }; + input.validate()?; + ctx.locator.auth().update_user_name(&id, input.name).await?; + Ok(true) + } + async fn register( ctx: &Context, email: String, diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 83a747186cd6..ebb76883b79d 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -219,6 +219,15 @@ impl AuthenticationService for AuthenticationServiceImpl { Ok(self.db.get_user_avatar(id.as_rowid()?).await?) } + async fn update_user_name(&self, id: &ID, name: String) -> Result<()> { + if is_demo_mode() { + bail!("Changing profile data is disabled in demo mode"); + } + let id = id.as_rowid()?; + self.db.update_user_name(id, name).await?; + Ok(()) + } + async fn token_auth(&self, email: String, password: String) -> Result { let Some(user) = self.db.get_user_by_email(&email).await? else { bail!("Invalid email address or password");