From 61b15b7b26e5a2b860d7743946d07449fe812728 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Thu, 9 Jan 2025 16:21:42 +0800 Subject: [PATCH] feat(graphQL): add ldap related apis (#3625) * feat(graphQL): add ldap related apis Signed-off-by: Wei Zhang # Conflicts: # Cargo.toml * feat(ui): add LDAP integration (#3650) * feat(ui): add LDAP integration * update * update * update * update * update Signed-off-by: Wei Zhang # Conflicts: # ee/tabby-ui/lib/tabby/query.ts * chore(ui): drop unused import Signed-off-by: Wei Zhang * chore(ui): fix style Signed-off-by: Wei Zhang --------- Signed-off-by: Wei Zhang Co-authored-by: aliang --- Cargo.lock | 36 ++ Cargo.toml | 1 + ee/tabby-db/src/lib.rs | 1 + ee/tabby-schema/Cargo.toml | 1 + ee/tabby-schema/graphql/schema.graphql | 50 ++ ee/tabby-schema/src/dao.rs | 50 +- ee/tabby-schema/src/schema/auth.rs | 106 +++- ee/tabby-schema/src/schema/mod.rs | 80 ++- ...redential-list.tsx => credential-list.tsx} | 65 ++- .../sso/components/form-sub-title.tsx | 8 + .../sso/components/ldap-credential-form.tsx | 524 ++++++++++++++++++ .../sso/components/oauth-credential-form.tsx | 43 +- .../sso/components/oauth-credential-new.tsx | 32 -- .../sso/components/sso-type-radio.tsx | 50 ++ .../sso/{components => }/constant.ts | 0 .../components/oauth-credential-detail.tsx | 18 +- .../sso/detail/[oauth-provider]/page.tsx | 26 + .../sso/detail/[provider]/page.tsx | 32 -- .../components/ldap-credential-detail.tsx | 51 ++ .../(integrations)/sso/detail/ldap/page.tsx | 7 + .../{components/sso-header.tsx => layout.tsx} | 17 +- .../sso/new/component/new-page.tsx | 33 ++ .../settings/(integrations)/sso/new/page.tsx | 8 +- .../settings/(integrations)/sso/page.tsx | 12 +- .../signin/components/ldap-signin-form.tsx | 114 ++++ .../auth/signin/components/signin-section.tsx | 79 ++- .../signin/components/user-signin-form.tsx | 4 +- ee/tabby-ui/lib/tabby/query.ts | 18 + ee/tabby-ui/lib/types/index.ts | 1 + ee/tabby-ui/lib/types/sso.ts | 1 + ee/tabby-webserver/Cargo.toml | 1 + ee/tabby-webserver/src/ldap.rs | 131 +++++ ee/tabby-webserver/src/lib.rs | 1 + ee/tabby-webserver/src/service/auth.rs | 287 +++++++++- .../src/service/auth/testutils.rs | 58 +- ee/tabby-webserver/src/service/mod.rs | 2 + 36 files changed, 1786 insertions(+), 162 deletions(-) rename ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/{oauth-credential-list.tsx => credential-list.tsx} (74%) create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/form-sub-title.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/ldap-credential-form.tsx delete mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-new.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/sso-type-radio.tsx rename ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/{components => }/constant.ts (100%) rename ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/{ => detail/[oauth-provider]}/components/oauth-credential-detail.tsx (70%) create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/detail/[oauth-provider]/page.tsx delete mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/detail/[provider]/page.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/detail/ldap/components/ldap-credential-detail.tsx create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/detail/ldap/page.tsx rename ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/{components/sso-header.tsx => layout.tsx} (68%) create mode 100644 ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/new/component/new-page.tsx create mode 100644 ee/tabby-ui/app/auth/signin/components/ldap-signin-form.tsx create mode 100644 ee/tabby-ui/lib/types/sso.ts create mode 100644 ee/tabby-webserver/src/ldap.rs diff --git a/Cargo.lock b/Cargo.lock index 08a3bb69bd61..e35fb4053bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2410,6 +2410,40 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "lber" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df7f9fd9f64cf8f59e1a4a0753fe7d575a5b38d3d7ac5758dcee9357d83ef0a" +dependencies = [ + "bytes", + "nom", +] + +[[package]] +name = "ldap3" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166199a8207874a275144c8a94ff6eed5fcbf5c52303e4d9b4d53a0c7ac76554" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lazy_static", + "lber", + "log", + "native-tls", + "nom", + "percent-encoding", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "leaky-bucket" version = "1.1.2" @@ -5510,6 +5544,7 @@ dependencies = [ "hash-ids", "juniper", "lazy_static", + "ldap3", "regex", "serde", "strum 0.24.1", @@ -5549,6 +5584,7 @@ dependencies = [ "juniper_axum", "juniper_graphql_ws", "lazy_static", + "ldap3", "lettre", "logkit", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index 865bbace6bcf..2af7cb06772a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ mime_guess = "2.0.4" assert_matches = "1.5" insta = "1.34.0" logkit = "0.3" +ldap3 = "0.11.0" async-openai-alt = "0.26.1" tracing-test = "0.2" clap = "4.3.0" diff --git a/ee/tabby-db/src/lib.rs b/ee/tabby-db/src/lib.rs index cc429db10d54..6b28bb435d8f 100644 --- a/ee/tabby-db/src/lib.rs +++ b/ee/tabby-db/src/lib.rs @@ -8,6 +8,7 @@ pub use email_setting::EmailSettingDAO; pub use integrations::IntegrationDAO; pub use invitations::InvitationDAO; pub use job_runs::JobRunDAO; +pub use ldap_credential::LdapCredentialDAO; pub use notifications::NotificationDAO; pub use oauth_credential::OAuthCredentialDAO; pub use provided_repositories::ProvidedRepositoryDAO; diff --git a/ee/tabby-schema/Cargo.toml b/ee/tabby-schema/Cargo.toml index b12e6b9e52cd..1939c0426c2d 100644 --- a/ee/tabby-schema/Cargo.toml +++ b/ee/tabby-schema/Cargo.toml @@ -30,6 +30,7 @@ validator = { version = "0.18.1", features = ["derive"] } regex.workspace = true hash-ids.workspace = true url.workspace = true +ldap3.workspace = true [dev-dependencies] tabby-db = { path = "../../ee/tabby-db", features = ["testutils"]} diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 9d35c0ea248a..80df435749c0 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -10,6 +10,13 @@ enum AuthMethod { LOGIN } +enum AuthProviderKind { + OAUTH_GITHUB + OAUTH_GOOGLE + OAUTH_GITLAB + LDAP +} + "Represents the kind of context source." enum ContextSourceKind { GIT @@ -61,6 +68,12 @@ enum Language { OTHER } +enum LdapEncryptionKind { + NONE + START_TLS + LDAPS +} + enum LicenseStatus { OK EXPIRED @@ -231,6 +244,19 @@ input UpdateIntegrationInput { kind: IntegrationKind! } +input UpdateLdapCredentialInput { + host: String! + port: Int! + bindDn: String! + bindPassword: String + baseDn: String! + userFilter: String! + encryption: LdapEncryptionKind! + skipTlsVerify: Boolean! + emailAttribute: String! + nameAttribute: String +} + input UpdateMessageInput { id: ID! threadId: ID! @@ -288,6 +314,10 @@ interface User { """ scalar DateTime +type AuthProvider { + kind: AuthProviderKind! +} + type CompletionStats { start: DateTime! end: DateTime! @@ -464,6 +494,20 @@ type JobStats { pending: Int! } +type LdapCredential { + host: String! + port: Int! + bindDn: String! + baseDn: String! + userFilter: String! + encryption: LdapEncryptionKind! + skipTlsVerify: Boolean! + emailAttribute: String! + nameAttribute: String + createdAt: DateTime! + updatedAt: DateTime! +} + type LicenseInfo { type: LicenseType! status: LicenseStatus! @@ -575,6 +619,7 @@ type Mutation { updateUserName(id: ID!, name: String!): Boolean! register(email: String!, password1: String!, password2: String!, invitationCode: String, name: String!): RegisterResponse! tokenAuth(email: String!, password: String!): TokenAuthResponse! + tokenAuthLdap(userId: String!, password: String!): TokenAuthResponse! verifyToken(token: String!): Boolean! refreshToken(refreshToken: String!): RefreshTokenResponse! createInvitation(email: String!): ID! @@ -586,6 +631,9 @@ type Mutation { deleteInvitation(id: ID!): ID! updateOauthCredential(input: UpdateOAuthCredentialInput!): Boolean! deleteOauthCredential(provider: OAuthProvider!): Boolean! + testLdapConnection(input: UpdateLdapCredentialInput!): Boolean! + updateLdapCredential(input: UpdateLdapCredentialInput!): Boolean! + deleteLdapCredential: Boolean! updateEmailSetting(input: EmailSettingInput!): Boolean! updateSecuritySetting(input: SecuritySettingInput!): Boolean! updateNetworkSetting(input: NetworkSettingInput!): Boolean! @@ -717,8 +765,10 @@ type Query { * `func_name lang:go` """ repositoryGrep(kind: RepositoryKind!, id: ID!, rev: String, query: String!): RepositoryGrepOutput! + authProviders: [AuthProvider!]! oauthCredential(provider: OAuthProvider!): OAuthCredential oauthCallbackUrl(provider: OAuthProvider!): String! + ldapCredential: LdapCredential serverInfo: ServerInfo! license: LicenseInfo! jobs: [String!]! diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index 61626d30c5c7..4ef782301b13 100644 --- a/ee/tabby-schema/src/dao.rs +++ b/ee/tabby-schema/src/dao.rs @@ -2,19 +2,20 @@ use anyhow::bail; use hash_ids::HashIds; use lazy_static::lazy_static; use tabby_db::{ - EmailSettingDAO, IntegrationDAO, InvitationDAO, JobRunDAO, NotificationDAO, OAuthCredentialDAO, - ServerSettingDAO, ThreadDAO, ThreadMessageAttachmentClientCode, ThreadMessageAttachmentCode, - ThreadMessageAttachmentDoc, ThreadMessageAttachmentIssueDoc, ThreadMessageAttachmentPullDoc, - ThreadMessageAttachmentWebDoc, UserEventDAO, + EmailSettingDAO, IntegrationDAO, InvitationDAO, JobRunDAO, LdapCredentialDAO, NotificationDAO, + OAuthCredentialDAO, ServerSettingDAO, ThreadDAO, ThreadMessageAttachmentClientCode, + ThreadMessageAttachmentCode, ThreadMessageAttachmentDoc, ThreadMessageAttachmentIssueDoc, + ThreadMessageAttachmentPullDoc, ThreadMessageAttachmentWebDoc, UserEventDAO, }; use crate::{ + auth::LdapEncryptionKind, integration::{Integration, IntegrationKind, IntegrationStatus}, interface::UserValue, notification::{Notification, NotificationRecipient}, repository::RepositoryKind, schema::{ - auth::{self, OAuthCredential, OAuthProvider}, + auth::{self, LdapCredential, OAuthCredential, OAuthProvider}, email::{AuthMethod, EmailSetting, Encryption}, job, repository::{ @@ -67,6 +68,26 @@ impl TryFrom for OAuthCredential { } } +impl TryFrom for LdapCredential { + type Error = anyhow::Error; + + fn try_from(val: LdapCredentialDAO) -> Result { + Ok(LdapCredential { + host: val.host, + port: val.port as i32, + bind_dn: val.bind_dn, + base_dn: val.base_dn, + user_filter: val.user_filter, + encryption: LdapEncryptionKind::from_enum_str(&val.encryption)?, + skip_tls_verify: val.skip_tls_verify, + email_attribute: val.email_attribute, + name_attribute: val.name_attribute, + created_at: val.created_at, + updated_at: val.updated_at, + }) + } +} + impl TryFrom for EmailSetting { type Error = anyhow::Error; @@ -447,6 +468,25 @@ impl DbEnum for OAuthProvider { } } +impl DbEnum for LdapEncryptionKind { + fn as_enum_str(&self) -> &'static str { + match self { + LdapEncryptionKind::None => "none", + LdapEncryptionKind::StartTLS => "starttls", + LdapEncryptionKind::LDAPS => "ldaps", + } + } + + fn from_enum_str(s: &str) -> anyhow::Result { + match s { + "none" => Ok(LdapEncryptionKind::None), + "starttls" => Ok(LdapEncryptionKind::StartTLS), + "ldaps" => Ok(LdapEncryptionKind::LDAPS), + _ => bail!("Invalid Ldap encryption kind"), + } + } +} + impl DbEnum for AuthMethod { fn as_enum_str(&self) -> &'static str { match self { diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 404da5d72a8c..237e0d4538d4 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -67,6 +67,15 @@ pub struct TokenAuthInput { pub password: String, } +/// Input parameters for token_auth_ldap mutation +#[derive(Validate)] +pub struct TokenAuthLdapInput<'a> { + #[validate(length(min = 1, code = "user_id", message = "User ID should not be empty"))] + pub user_id: &'a str, + #[validate(length(min = 1, code = "password", message = "Password should not be empty"))] + pub password: &'a str, +} + /// Input parameters for register mutation /// `validate` attribute is used to validate the input parameters /// - `code` argument specifies which parameter causes the failure @@ -322,6 +331,35 @@ pub enum OAuthProvider { Gitlab, } +#[derive(GraphQLEnum, Clone, Serialize, Deserialize, PartialEq, Debug)] +pub enum AuthProviderKind { + OAuthGithub, + OAuthGoogle, + OAuthGitlab, + Ldap, +} + +impl From for AuthProvider { + fn from(provider: OAuthProvider) -> Self { + match provider { + OAuthProvider::Github => AuthProvider { + kind: AuthProviderKind::OAuthGithub, + }, + OAuthProvider::Google => AuthProvider { + kind: AuthProviderKind::OAuthGoogle, + }, + OAuthProvider::Gitlab => AuthProvider { + kind: AuthProviderKind::OAuthGitlab, + }, + } + } +} + +#[derive(GraphQLObject)] +pub struct AuthProvider { + pub kind: AuthProviderKind, +} + #[derive(GraphQLObject)] pub struct OAuthCredential { pub provider: OAuthProvider, @@ -348,6 +386,65 @@ pub struct UpdateOAuthCredentialInput { pub client_secret: Option, } +#[derive(GraphQLEnum, PartialEq, Debug)] +pub enum LdapEncryptionKind { + None, + StartTLS, + LDAPS, +} + +#[derive(GraphQLInputObject, Validate)] +pub struct UpdateLdapCredentialInput { + #[validate(length( + min = 1, + code = "host", + message = "host should not be empty and should be a valid hostname or IP address" + ))] + pub host: String, + pub port: i32, + + #[validate(length(min = 1, code = "bindDn", message = "bindDn cannot be empty"))] + pub bind_dn: String, + pub bind_password: Option, + + #[validate(length(min = 1, code = "baseDn", message = "baseDn cannot be empty"))] + pub base_dn: String, + #[validate(length( + min = 1, + code = "userFilter", + message = "userFilter cannot be empty, and should be in the format of `(uid=%s)`" + ))] + pub user_filter: String, + + pub encryption: LdapEncryptionKind, + pub skip_tls_verify: bool, + + #[validate(length( + min = 1, + code = "emailAttribute", + message = "emailAttribute cannot be empty" + ))] + pub email_attribute: String, + // if name_attribute is None, we will use username as name + pub name_attribute: Option, +} + +#[derive(GraphQLObject)] +pub struct LdapCredential { + pub host: String, + pub port: i32, + pub bind_dn: String, + pub base_dn: String, + pub user_filter: String, + pub encryption: LdapEncryptionKind, + pub skip_tls_verify: bool, + pub email_attribute: String, + pub name_attribute: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + #[async_trait] pub trait AuthenticationService: Send + Sync { async fn register( @@ -361,6 +458,8 @@ pub trait AuthenticationService: Send + Sync { async fn token_auth(&self, email: String, password: String) -> Result; + async fn token_auth_ldap(&self, email: &str, password: &str) -> Result; + async fn refresh_token(&self, refresh_token: String) -> Result; async fn verify_access_token(&self, access_token: &str) -> Result; async fn verify_auth_token(&self, token: &str) -> Result; @@ -414,8 +513,13 @@ pub trait AuthenticationService: Send + Sync { ) -> Result>; async fn update_oauth_credential(&self, input: UpdateOAuthCredentialInput) -> Result<()>; - async fn delete_oauth_credential(&self, provider: OAuthProvider) -> Result<()>; + + async fn read_ldap_credential(&self) -> Result>; + async fn test_ldap_connection(&self, input: UpdateLdapCredentialInput) -> Result<()>; + async fn update_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()>; + async fn delete_ldap_credential(&self) -> Result<()>; + async fn update_user_active(&self, id: &ID, active: bool) -> Result<()>; async fn update_user_role(&self, id: &ID, is_admin: bool) -> Result<()>; async fn update_user_avatar(&self, id: &ID, avatar: Option>) -> Result<()>; diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 6424a9a1f8ad..17a315e5978f 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -28,7 +28,8 @@ use async_openai_alt::{ }, }; use auth::{ - AuthenticationService, Invitation, RefreshTokenResponse, RegisterResponse, TokenAuthResponse, + AuthProvider, AuthProviderKind, AuthenticationService, Invitation, LdapCredential, + RefreshTokenResponse, RegisterResponse, TokenAuthResponse, UpdateLdapCredentialInput, UserSecured, }; use base64::Engine; @@ -41,8 +42,10 @@ use juniper::{ graphql_object, graphql_subscription, graphql_value, FieldError, GraphQLEnum, GraphQLObject, IntoFieldError, Object, RootNode, ScalarValue, Value, ID, }; +use ldap3::result::LdapError; use notification::NotificationService; use repository::RepositoryGrepOutput; +use strum::IntoEnumIterator; use tabby_common::{ api::{code::CodeSearch, event::EventLogger}, config::CompletionConfig, @@ -145,6 +148,12 @@ pub enum CoreError { Other(#[from] anyhow::Error), } +impl From for CoreError { + fn from(err: LdapError) -> Self { + Self::Other(err.into()) + } +} + impl IntoFieldError for CoreError { fn into_field_error(self) -> FieldError { match self { @@ -429,6 +438,29 @@ impl Query { Ok(RepositoryGrepOutput { files, elapsed_ms }) } + async fn auth_providers(ctx: &Context) -> Result> { + let mut providers = vec![]; + + let auth = ctx.locator.auth(); + for x in OAuthProvider::iter() { + if auth + .read_oauth_credential(x.clone()) + .await + .is_ok_and(|x| x.is_some()) + { + providers.push(x.into()); + } + } + + if auth.read_ldap_credential().await.is_ok_and(|x| x.is_some()) { + providers.push(AuthProvider { + kind: AuthProviderKind::Ldap, + }); + } + + Ok(providers) + } + async fn oauth_credential( ctx: &Context, provider: OAuthProvider, @@ -442,6 +474,11 @@ impl Query { ctx.locator.auth().oauth_callback_url(provider).await } + async fn ldap_credential(ctx: &Context) -> Result> { + check_admin(ctx).await?; + ctx.locator.auth().read_ldap_credential().await + } + async fn server_info(ctx: &Context) -> Result { Ok(ServerInfo { is_admin_initialized: ctx.locator.auth().is_admin_initialized().await?, @@ -975,6 +1012,22 @@ impl Mutation { .await } + async fn token_auth_ldap( + ctx: &Context, + user_id: String, + password: String, + ) -> Result { + let input = auth::TokenAuthLdapInput { + user_id: &user_id, + password: &password, + }; + input.validate()?; + ctx.locator + .auth() + .token_auth_ldap(&user_id, &password) + .await + } + async fn verify_token(ctx: &Context, token: String) -> Result { ctx.locator.auth().verify_access_token(&token).await?; Ok(true) @@ -1058,6 +1111,31 @@ impl Mutation { Ok(true) } + async fn test_ldap_connection(ctx: &Context, input: UpdateLdapCredentialInput) -> Result { + check_admin(ctx).await?; + check_license(ctx, &[LicenseType::Enterprise]).await?; + ctx.locator.auth().test_ldap_connection(input).await?; + Ok(true) + } + + async fn update_ldap_credential( + ctx: &Context, + input: UpdateLdapCredentialInput, + ) -> Result { + check_admin(ctx).await?; + check_license(ctx, &[LicenseType::Enterprise]).await?; + input.validate()?; + + ctx.locator.auth().update_ldap_credential(input).await?; + Ok(true) + } + + async fn delete_ldap_credential(ctx: &Context) -> Result { + check_admin(ctx).await?; + ctx.locator.auth().delete_ldap_credential().await?; + Ok(true) + } + async fn update_email_setting(ctx: &Context, input: EmailSettingInput) -> Result { check_admin(ctx).await?; input.validate()?; diff --git a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-list.tsx b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/credential-list.tsx similarity index 74% rename from ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-list.tsx rename to ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/credential-list.tsx index 54ab9165b1ae..eb6383cb1863 100644 --- a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-list.tsx +++ b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/credential-list.tsx @@ -8,19 +8,25 @@ import { useQuery } from 'urql' import { graphql } from '@/lib/gql/generates' import { + LdapCredentialQuery, LicenseType, OAuthCredentialQuery, OAuthProvider } from '@/lib/gql/generates/graphql' +import { ldapCredentialQuery } from '@/lib/tabby/query' import { Button, buttonVariants } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { IconGitHub, IconGitLab, IconGoogle } from '@/components/ui/icons' +import { + IconGitHub, + IconGitLab, + IconGoogle, + IconUsers +} from '@/components/ui/icons' import { Skeleton } from '@/components/ui/skeleton' import { LicenseGuard } from '@/components/license-guard' import LoadingWrapper from '@/components/loading-wrapper' -import { PROVIDER_METAS } from './constant' -import { SSOHeader } from './sso-header' +import { PROVIDER_METAS } from '../constant' export const oauthCredential = graphql(/* GraphQL */ ` query OAuthCredential($provider: OAuthProvider!) { @@ -33,7 +39,7 @@ export const oauthCredential = graphql(/* GraphQL */ ` } `) -const OAuthCredentialList = () => { +export const CredentialList = () => { const [{ data: githubData, fetching: fetchingGithub }] = useQuery({ query: oauthCredential, variables: { provider: OAuthProvider.Github } @@ -47,7 +53,12 @@ const OAuthCredentialList = () => { variables: { provider: OAuthProvider.Gitlab } }) - const isLoading = fetchingGithub || fetchingGoogle || fetchingGitlab + const [{ data: ldapData, fetching: fetchingLdap }] = useQuery({ + query: ldapCredentialQuery + }) + + const isLoading = + fetchingGithub || fetchingGoogle || fetchingGitlab || fetchingLdap const credentialList = React.useMemo(() => { return compact([ githubData?.oauthCredential, @@ -73,7 +84,6 @@ const OAuthCredentialList = () => { if (!credentialList?.length) { return (
- { return (
-
{credentialList.map(credential => { return ( ) })} +
{credentialList.length < 3 && (
{createButton}
@@ -149,7 +159,7 @@ const OauthCredentialCard = ({ OAuth 2.0
- Domain + Host {meta?.domain}
@@ -157,6 +167,43 @@ const OauthCredentialCard = ({ ) } +const LDAPCredentialCard = ({ + data +}: { + data: LdapCredentialQuery['ldapCredential'] +}) => { + if (!data) return null + + return ( + + +
+ + + LDAP + + + View + +
+
+ +
+ Type + LDAP +
+
+ Host + {data?.host} +
+
+
+ ) +} + function OAuthProviderIcon({ provider }: { provider: OAuthProvider }) { switch (provider) { case OAuthProvider.Github: @@ -169,5 +216,3 @@ function OAuthProviderIcon({ provider }: { provider: OAuthProvider }) { return null } } - -export { OAuthCredentialList } diff --git a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/form-sub-title.tsx b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/form-sub-title.tsx new file mode 100644 index 000000000000..fc9ec7a96a69 --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/form-sub-title.tsx @@ -0,0 +1,8 @@ +import { cn } from '@/lib/utils' + +export function SubTitle({ + className, + ...rest +}: React.HTMLAttributes) { + return
+} diff --git a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/ldap-credential-form.tsx b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/ldap-credential-form.tsx new file mode 100644 index 000000000000..eca1bafb668d --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/ldap-credential-form.tsx @@ -0,0 +1,524 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { zodResolver } from '@hookform/resolvers/zod' +import { isEmpty } from 'lodash-es' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { useClient } from 'urql' +import * as z from 'zod' + +import { graphql } from '@/lib/gql/generates' +import { LdapEncryptionKind, LicenseType } from '@/lib/gql/generates/graphql' +import { useMutation } from '@/lib/tabby/gql' +import { ldapCredentialQuery } from '@/lib/tabby/query' +import { cn } from '@/lib/utils' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { Button, buttonVariants } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { IconSpinner } from '@/components/ui/icons' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { LicenseGuard } from '@/components/license-guard' + +import { SubTitle } from './form-sub-title' + +const testLdapConnectionMutation = graphql(/* GraphQL */ ` + mutation testLdapConnection($input: UpdateLdapCredentialInput!) { + testLdapConnection(input: $input) + } +`) + +const updateLdapCredentialMutation = graphql(/* GraphQL */ ` + mutation updateLdapCredential($input: UpdateLdapCredentialInput!) { + updateLdapCredential(input: $input) + } +`) + +const deleteLdapCredentialMutation = graphql(/* GraphQL */ ` + mutation deleteLdapCredential { + deleteLdapCredential + } +`) + +const formSchema = z.object({ + host: z.string(), + port: z.coerce.number({ + required_error: 'Required', + invalid_type_error: 'Invalid port' + }), + bindDn: z.string(), + bindPassword: z.string().optional(), + baseDn: z.string(), + userFilter: z.string(), + encryption: z.nativeEnum(LdapEncryptionKind), + skipTlsVerify: z.boolean(), + emailAttribute: z.string(), + nameAttribute: z.string().optional() +}) + +export type LDAPFormValues = z.infer + +interface LDAPFormProps extends React.HTMLAttributes { + isNew?: boolean + defaultValues?: Partial | undefined + onSuccess?: (formValues: LDAPFormValues) => void +} + +export function LDAPCredentialForm({ + className, + isNew, + defaultValues, + onSuccess, + ...props +}: LDAPFormProps) { + const router = useRouter() + const client = useClient() + const formRef = React.useRef(null) + const [isTesting, setIsTesting] = React.useState(false) + const formatedDefaultValues = React.useMemo(() => { + return { + ...(defaultValues || {}) + } + }, []) + + const [deleteAlertVisible, setDeleteAlertVisible] = React.useState(false) + const [isDeleting, setIsDeleting] = React.useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: formatedDefaultValues + }) + const isDirty = !isEmpty(form.formState.dirtyFields) + const isValid = form.formState.isValid + const { isSubmitting } = form.formState + + const navigateToSSOSettings = () => { + router.replace('/settings/sso') + } + + const updateOauthCredential = useMutation(updateLdapCredentialMutation, { + onCompleted(values) { + if (values?.updateLdapCredential) { + onSuccess?.(form.getValues()) + } + }, + form + }) + + const testLdapConnection = useMutation(testLdapConnectionMutation, { + onError(err) { + toast.error(err.message) + }, + onCompleted(data) { + if (data?.testLdapConnection) { + toast.success('LDAP connection test success.', { + className: 'mb-10' + }) + } else { + toast.error('LDAP connection test failed.') + } + } + }) + + const deleteLdapCredential = useMutation(deleteLdapCredentialMutation) + + const onSubmit = async (values: LDAPFormValues) => { + if (isNew) { + const hasExistingProvider = await client + .query(ldapCredentialQuery, {}) + .then(res => !!res?.data?.ldapCredential) + if (hasExistingProvider) { + form.setError('root', { + message: 'Provider already exists.' + }) + return + } + } + + return updateOauthCredential({ input: values }) + } + + const onDelete: React.MouseEventHandler = e => { + e.preventDefault() + setIsDeleting(true) + deleteLdapCredential().then(res => { + if (res?.data?.deleteLdapCredential) { + navigateToSSOSettings() + } else { + setIsDeleting(false) + if (res?.error) { + toast.error(res?.error?.message) + } + } + }) + } + + const onTestLdapCredential = () => { + if (!formRef.current) return + form.trigger().then(isValid => { + if (!isValid) return + + setIsTesting(true) + + return testLdapConnection({ + input: formSchema.parse(form.getValues()) + }).finally(() => { + setIsTesting(false) + }) + }) + } + + const passwordPlaceholder = React.useMemo(() => { + if (!isNew) return new Array(36).fill('*').join('') + + return undefined + }, [isNew]) + + return ( +
+
+ +
+ LDAP provider information + + The information is provided by your identity provider. + +
+
+ ( + + Host + + + + + + )} + /> + ( + + Port + + + + + + )} + /> +
+
+ ( + + Bind DN + + + + + + )} + /> + ( + + Bind Password + + + + + + )} + /> +
+ ( + + Base DN + + + + + + )} + /> + ( + + User Filter + + + + + + )} + /> + ( + + Encryption + + + + )} + /> + ( + + Connection security +
+ + + + + Skip TLS Verify + +
+ +
+ )} + /> +
+ User information mapping + + Maps the field names from user info API to the Tabby user. + +
+ ( + + Email + + + + + + )} + /> + ( + + Name + + + + + + )} + /> +
+ +
+ + + +
+ + {!isNew && ( + + + + + + + + Are you absolutely sure? + + + This action cannot be undone. It will permanently delete + the current credential. + + + + Cancel + + {isDeleting && ( + + )} + Yes, delete it + + + + + )} + + {({ hasValidLicense }) => ( + + )} + +
+ + +
+ + ) +} diff --git a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-form.tsx b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-form.tsx index b9cf506117c6..7915376d45a0 100644 --- a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-form.tsx +++ b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/sso/components/oauth-credential-form.tsx @@ -46,7 +46,8 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { CopyButton } from '@/components/copy-button' import { LicenseGuard } from '@/components/license-guard' -import { oauthCredential } from './oauth-credential-list' +import { oauthCredential } from './credential-list' +import { SubTitle } from './form-sub-title' export const updateOauthCredentialMutation = graphql(/* GraphQL */ ` mutation updateOauthCredential($input: UpdateOAuthCredentialInput!) { @@ -171,19 +172,8 @@ export default function OAuthCredentialForm({ return (
- - Basic information - - - -
- - -
-
-
+ + Basic information -
+
-
+
-
+