diff --git a/crates/admin/src/meta.rs b/crates/admin/src/meta.rs index d0118be..45efed6 100644 --- a/crates/admin/src/meta.rs +++ b/crates/admin/src/meta.rs @@ -18,9 +18,9 @@ use bichon_core::{ token::TokenType, users::{acl::AccessControl, role::RoleType}, }; +use bichon_memdb::{Durability, MemDb}; use console::style; use itertools::Itertools; -use bichon_memdb::{Durability, MemDb}; use native_db::*; use native_model::{native_model, Model}; use serde::{Deserialize, Serialize}; @@ -574,6 +574,8 @@ impl From for bichon_core::users::BichonUserV2 { acl: value.acl, theme: value.theme, language: value.language, + sso_id: None, + sso_provider: None, } } } diff --git a/crates/core/src/ext/text_extractor.rs b/crates/core/src/ext/text_extractor.rs index baf1243..3a3a4af 100644 --- a/crates/core/src/ext/text_extractor.rs +++ b/crates/core/src/ext/text_extractor.rs @@ -63,8 +63,18 @@ pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024; pub fn should_try_extract(content_type: &str, ext: &str) -> bool { matches!( ext, - "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" - | "txt" | "rtf" | "odt" | "ods" | "odp" + "pdf" + | "doc" + | "docx" + | "xls" + | "xlsx" + | "ppt" + | "pptx" + | "txt" + | "rtf" + | "odt" + | "ods" + | "odp" ) || content_type.starts_with("text/") } diff --git a/crates/core/src/settings/cli.rs b/crates/core/src/settings/cli.rs index 3c1f091..627aa5b 100644 --- a/crates/core/src/settings/cli.rs +++ b/crates/core/src/settings/cli.rs @@ -308,6 +308,26 @@ pub struct Settings { help = "Enable SMTP authentication requirement" )] pub bichon_smtp_auth_required: bool, + + /// Enable OIDC-based Single Sign-On (Pro/Enterprise feature). + #[clap(long, default_value = "false", env, help = "Enable OpenID Connect SSO")] + pub bichon_oidc_enabled: bool, + + /// OIDC issuer URL (e.g. https://keycloak.example.com/realms/myorg). + #[clap(long, env, help = "OpenID Connect issuer URL")] + pub bichon_oidc_issuer_url: Option, + + /// OIDC client ID registered with the IdP. + #[clap(long, env, help = "OpenID Connect client ID")] + pub bichon_oidc_client_id: Option, + + /// OIDC client secret registered with the IdP. + #[clap(long, env, help = "OpenID Connect client secret")] + pub bichon_oidc_client_secret: Option, + + /// OIDC redirect URI (must match what's registered with the IdP). + #[clap(long, env, help = "OpenID Connect redirect URI")] + pub bichon_oidc_redirect_uri: Option, } impl Settings { @@ -317,9 +337,8 @@ impl Settings { // rejects it, fall back to parsing with only the binary name so that // the settings come entirely from environment variables. let args: Vec = std::env::args().collect(); - let s = Self::try_parse_from(&args).unwrap_or_else(|_| { - Self::parse_from(std::iter::once(args[0].clone())) - }); + let s = Self::try_parse_from(&args) + .unwrap_or_else(|_| Self::parse_from(std::iter::once(args[0].clone()))); if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() { panic!( "One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set" diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index 827e836..7d4e203 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -60,6 +60,11 @@ pub struct SystemConfigurations { pub bichon_smtp_auth_required: bool, pub bichon_smtp_tls_key_path: Option, pub bichon_smtp_tls_cert_path: Option, + + pub bichon_oidc_enabled: bool, + pub bichon_oidc_issuer_url: Option, + pub bichon_oidc_client_id: Option, + pub bichon_oidc_redirect_uri: Option, } impl From<&Settings> for SystemConfigurations { @@ -94,6 +99,10 @@ impl From<&Settings> for SystemConfigurations { bichon_smtp_auth_required: s.bichon_smtp_auth_required, bichon_smtp_tls_key_path: s.bichon_smtp_tls_key_path.clone(), bichon_smtp_tls_cert_path: s.bichon_smtp_tls_cert_path.clone(), + bichon_oidc_enabled: s.bichon_oidc_enabled, + bichon_oidc_issuer_url: s.bichon_oidc_issuer_url.clone(), + bichon_oidc_client_id: s.bichon_oidc_client_id.clone(), + bichon_oidc_redirect_uri: s.bichon_oidc_redirect_uri.clone(), } } } diff --git a/crates/core/src/users/mod.rs b/crates/core/src/users/mod.rs index 9e6d994..84b74d7 100644 --- a/crates/core/src/users/mod.rs +++ b/crates/core/src/users/mod.rs @@ -87,6 +87,11 @@ pub struct BichonUserV2 { pub theme: Option, pub language: Option, + + /// SSO identity: unique subject ID from the external IdP (e.g. OIDC `sub` claim). + pub sso_id: Option, + /// SSO provider identifier: `"oidc"` or future `"saml"` / `"ldap"`. + pub sso_provider: Option, } impl MemDbModel for BichonUserV2 { @@ -192,6 +197,8 @@ impl BichonUserV2 { global_permissions, theme: self.theme, language: self.language, + sso_id: self.sso_id, + sso_provider: self.sso_provider, } } @@ -226,6 +233,8 @@ impl BichonUserV2 { acl: None, theme: None, language: None, + sso_id: None, + sso_provider: None, }; // 3. Generate and insert an initial access token for the first-time setup @@ -382,6 +391,8 @@ impl BichonUserV2 { account_access_map: request.account_access_map, theme: request.theme, language: request.language, + sso_id: None, + sso_provider: None, }; let user_clone = user.clone(); diff --git a/crates/core/src/users/view.rs b/crates/core/src/users/view.rs index 2839635..4ef909d 100644 --- a/crates/core/src/users/view.rs +++ b/crates/core/src/users/view.rs @@ -52,4 +52,9 @@ pub struct UserView { pub acl: Option, pub theme: Option, pub language: Option, + + /// SSO identity: unique subject ID from the external IdP (e.g. OIDC `sub` claim). + pub sso_id: Option, + /// SSO provider identifier: `"oidc"` or future `"saml"` / `"ldap"`. + pub sso_provider: Option, } diff --git a/crates/core/src/utils/mod.rs b/crates/core/src/utils/mod.rs index 35ef0a6..49c463a 100644 --- a/crates/core/src/utils/mod.rs +++ b/crates/core/src/utils/mod.rs @@ -138,7 +138,7 @@ macro_rules! generate_token { }}; } -pub(crate) fn generate_token_impl(bit_strength: usize) -> String { +pub fn generate_token_impl(bit_strength: usize) -> String { let byte_length = (bit_strength + 23) / 24 * 3; let random_bytes: Vec = (0..byte_length).map(|_| rand::random::()).collect(); let mut encoded = general_purpose::URL_SAFE.encode(&random_bytes); diff --git a/web/src/features/auth/user-auth-form.tsx b/web/src/features/auth/user-auth-form.tsx index 4b4bcc7..3b2e340 100644 --- a/web/src/features/auth/user-auth-form.tsx +++ b/web/src/features/auth/user-auth-form.tsx @@ -41,9 +41,10 @@ import { useLocation, useNavigate } from '@tanstack/react-router' import { Button } from '@/components/button' import { useTranslation } from 'react-i18next' import i18n from '@/i18n' -import { Loader2, LogIn } from 'lucide-react' +import { Loader2, LogIn, Shield } from 'lucide-react' import { login } from '@/api/users/api' import { useTheme } from '@/context/theme-context' +import { useEdition } from '@/hooks/use-edition' type UserAuthFormProps = HTMLAttributes @@ -52,6 +53,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const { setTheme } = useTheme(); const navigate = useNavigate() const { t } = useTranslation() + const { isPro } = useEdition() const { search } = useLocation(); const redirect = toSearchParams(search).get('redirect') || '/'; @@ -156,6 +158,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { {isLoading ? : } {t('auth.login')} + + {isPro && ( + + )} diff --git a/web/src/hooks/use-edition.ts b/web/src/hooks/use-edition.ts new file mode 100644 index 0000000..b436fd4 --- /dev/null +++ b/web/src/hooks/use-edition.ts @@ -0,0 +1,28 @@ +import axiosInstance from '@/api/axiosInstance' +import { useQuery } from '@tanstack/react-query' + +export interface EditionInfo { + features: string[] + edition: 'community' | 'pro' | 'enterprise' + version: string +} + +async function fetchEdition(): Promise { + const { data } = await axiosInstance.get('api/v1/features') + return data +} + +export function useEdition() { + const { data } = useQuery({ + queryKey: ['edition'], + queryFn: fetchEdition, + staleTime: Infinity, + retry: 1, + }) + + return { + isPro: data?.edition === 'pro' || data?.edition === 'enterprise', + edition: data?.edition ?? 'community', + features: data?.features ?? [], + } as const +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 238bcc7..35eadb6 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -423,6 +423,7 @@ "sessionExpired": "Session expired!", "sessionExpiredDesc": "Your session has ended due to inactivity. Please log in again to continue.", "somethingWentWrong": "Something went wrong", + "ssoLogin": "Sign in with SSO", "username": "Username", "welcome": "Welcome to Bichon", "youWillNeedToLogInAgain": "You will need to log in again to access your account."