feat(admin): add admin settings panel for OIDC configuration
- Admin UI at /admin.html with settings management interface - REST API: GET/PUT /api/admin/settings/oidc, POST .../test, GET .../general - DB-backed settings in auth.admin_settings table (PostgreSQL) - OIDC auto-discovery from issuer URL (.well-known/openid-configuration) - Hot-reload: OIDC config changes apply without server restart - Role-based access: admin-only endpoints with 403 for regular users - Client secret stored securely, never exposed in GET responses - Env var override detection shown in admin UI - Clean architecture: repository trait, PG implementation, service, handler
This commit is contained in:
@@ -120,6 +120,20 @@ COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilizati
|
||||
COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization';
|
||||
COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization';
|
||||
|
||||
-- Admin settings (key-value store for platform configuration)
|
||||
CREATE TABLE IF NOT EXISTS auth.admin_settings (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||
is_secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(36)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_settings_category ON auth.admin_settings(category);
|
||||
|
||||
COMMENT ON TABLE auth.admin_settings IS 'Platform configuration settings managed via admin panel';
|
||||
|
||||
-- OIDC identity linking columns
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oidc_provider VARCHAR(255);
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255);
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod i18n_dto;
|
||||
pub mod pagination;
|
||||
pub mod recent_dto;
|
||||
pub mod search_dto;
|
||||
pub mod settings_dto;
|
||||
pub mod share_dto;
|
||||
pub mod trash_dto;
|
||||
pub mod user_dto;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Settings DTOs (Admin Panel)
|
||||
// ============================================================================
|
||||
|
||||
/// Current OIDC settings returned to admin UI (secrets masked)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcSettingsDto {
|
||||
pub enabled: bool,
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
/// True if a client secret is configured (never reveals the actual value)
|
||||
pub client_secret_set: bool,
|
||||
pub scopes: String,
|
||||
pub auto_provision: bool,
|
||||
pub admin_groups: String,
|
||||
pub disable_password_login: bool,
|
||||
pub provider_name: String,
|
||||
/// Auto-generated callback URL the admin must register in their IdP
|
||||
pub callback_url: String,
|
||||
/// Field names overridden by environment variables (read-only in UI)
|
||||
pub env_overrides: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request body for saving OIDC settings from the admin panel
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SaveOidcSettingsDto {
|
||||
pub enabled: bool,
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
/// Only update if provided and non-empty (None = keep existing)
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Option<String>,
|
||||
pub auto_provision: Option<bool>,
|
||||
pub admin_groups: Option<String>,
|
||||
pub disable_password_login: Option<bool>,
|
||||
pub provider_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for testing OIDC discovery
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TestOidcConnectionDto {
|
||||
pub issuer_url: String,
|
||||
}
|
||||
|
||||
/// Result of OIDC connection test
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcTestResultDto {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub issuer: Option<String>,
|
||||
pub authorization_endpoint: Option<String>,
|
||||
pub token_endpoint: Option<String>,
|
||||
pub userinfo_endpoint: Option<String>,
|
||||
/// Suggested provider name (derived from issuer hostname)
|
||||
pub provider_name_suggestion: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::application::dtos::settings_dto::{
|
||||
OidcSettingsDto, SaveOidcSettingsDto, OidcTestResultDto, TestOidcConnectionDto,
|
||||
};
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Admin settings service — manages platform configuration in the database.
|
||||
///
|
||||
/// Configuration priority: **env vars > DB settings > defaults**.
|
||||
/// Supports hot-reloading OIDC configuration without server restart.
|
||||
pub struct AdminSettingsService {
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
}
|
||||
|
||||
impl AdminSettingsService {
|
||||
pub fn new(
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings_repo,
|
||||
env_oidc_config,
|
||||
auth_app_service,
|
||||
server_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-generated OIDC callback URL
|
||||
fn callback_url(&self) -> String {
|
||||
let base = self.server_base_url.trim_end_matches('/');
|
||||
format!("{}/api/auth/oidc/callback", base)
|
||||
}
|
||||
|
||||
/// Detect which OIDC fields are overridden by environment variables
|
||||
fn get_env_overrides(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let vars = [
|
||||
("OXICLOUD_OIDC_ENABLED", "enabled"),
|
||||
("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"),
|
||||
("OXICLOUD_OIDC_CLIENT_ID", "client_id"),
|
||||
("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"),
|
||||
("OXICLOUD_OIDC_SCOPES", "scopes"),
|
||||
("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"),
|
||||
("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"),
|
||||
("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN", "disable_password_login"),
|
||||
("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"),
|
||||
];
|
||||
for (env_key, field_name) in &vars {
|
||||
if std::env::var(env_key).is_ok() {
|
||||
out.push(field_name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides on top of a config
|
||||
fn apply_env_overrides(&self, config: &mut OidcConfig) {
|
||||
let e = &self.env_oidc_config;
|
||||
if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() { config.enabled = e.enabled; }
|
||||
if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() { config.issuer_url = e.issuer_url.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() { config.client_id = e.client_id.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() { config.client_secret = e.client_secret.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() { config.scopes = e.scopes.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() { config.redirect_uri = e.redirect_uri.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() { config.frontend_url = e.frontend_url.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() { config.auto_provision = e.auto_provision; }
|
||||
if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() { config.admin_groups = e.admin_groups.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() { config.disable_password_login = e.disable_password_login; }
|
||||
if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() { config.provider_name = e.provider_name.clone(); }
|
||||
}
|
||||
|
||||
/// Load the effective OIDC config: DB settings + env var overrides + defaults.
|
||||
pub async fn load_effective_oidc_config(&self) -> Result<OidcConfig, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let mut config = OidcConfig {
|
||||
enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id),
|
||||
client_secret: db.get("oidc.client_secret").cloned().unwrap_or(d.client_secret),
|
||||
redirect_uri: self.callback_url(),
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
frontend_url: self.server_base_url.clone(),
|
||||
auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or(d.admin_groups),
|
||||
disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login),
|
||||
provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name),
|
||||
};
|
||||
|
||||
// Env vars override DB
|
||||
self.apply_env_overrides(&mut config);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get OIDC settings for display in admin UI (secrets masked).
|
||||
pub async fn get_oidc_settings(&self) -> Result<OidcSettingsDto, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let has_secret = db.get("oidc.client_secret").map(|s| !s.is_empty()).unwrap_or(false)
|
||||
|| std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
Ok(OidcSettingsDto {
|
||||
enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or_default(),
|
||||
client_secret_set: has_secret,
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(),
|
||||
disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login),
|
||||
provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name),
|
||||
callback_url: self.callback_url(),
|
||||
env_overrides: self.get_env_overrides(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save OIDC settings to DB and hot-reload the OIDC service.
|
||||
pub async fn save_oidc_settings(
|
||||
&self,
|
||||
dto: SaveOidcSettingsDto,
|
||||
updated_by: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let cat = "oidc";
|
||||
let by = Some(updated_by);
|
||||
|
||||
self.settings_repo.set("oidc.enabled", &dto.enabled.to_string(), cat, false, by).await?;
|
||||
self.settings_repo.set("oidc.issuer_url", &dto.issuer_url, cat, false, by).await?;
|
||||
self.settings_repo.set("oidc.client_id", &dto.client_id, cat, false, by).await?;
|
||||
|
||||
if let Some(ref secret) = dto.client_secret {
|
||||
if !secret.is_empty() {
|
||||
self.settings_repo.set("oidc.client_secret", secret, cat, true, by).await?;
|
||||
}
|
||||
}
|
||||
if let Some(ref v) = dto.scopes {
|
||||
self.settings_repo.set("oidc.scopes", v, cat, false, by).await?;
|
||||
}
|
||||
if let Some(v) = dto.auto_provision {
|
||||
self.settings_repo.set("oidc.auto_provision", &v.to_string(), cat, false, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.admin_groups {
|
||||
self.settings_repo.set("oidc.admin_groups", v, cat, false, by).await?;
|
||||
}
|
||||
if let Some(v) = dto.disable_password_login {
|
||||
self.settings_repo.set("oidc.disable_password_login", &v.to_string(), cat, false, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.provider_name {
|
||||
self.settings_repo.set("oidc.provider_name", v, cat, false, by).await?;
|
||||
}
|
||||
|
||||
// Hot-reload OIDC service
|
||||
let eff = self.load_effective_oidc_config().await?;
|
||||
if eff.enabled && !eff.issuer_url.is_empty()
|
||||
&& !eff.client_id.is_empty() && !eff.client_secret.is_empty()
|
||||
{
|
||||
let svc = Arc::new(OidcService::new(eff.clone()));
|
||||
self.auth_app_service.reload_oidc(svc, eff);
|
||||
tracing::info!("OIDC service hot-reloaded with new configuration");
|
||||
} else if !eff.enabled {
|
||||
self.auth_app_service.disable_oidc();
|
||||
tracing::info!("OIDC service disabled via admin panel");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test OIDC connection by fetching the discovery document.
|
||||
pub async fn test_oidc_connection(
|
||||
&self,
|
||||
dto: TestOidcConnectionDto,
|
||||
) -> Result<OidcTestResultDto, DomainError> {
|
||||
let issuer = dto.issuer_url.trim_end_matches('/');
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", issuer);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", format!("HTTP client error: {}", e),
|
||||
))?;
|
||||
|
||||
let resp = match client.get(&discovery_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Cannot reach the OIDC provider: {}. Check your Issuer URL.", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!(
|
||||
"OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.",
|
||||
resp.status()
|
||||
),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Discovery {
|
||||
issuer: Option<String>,
|
||||
authorization_endpoint: Option<String>,
|
||||
token_endpoint: Option<String>,
|
||||
userinfo_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
let disc: Discovery = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Invalid discovery document: {}", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Suggest provider name from hostname
|
||||
let suggestion = issuer
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.and_then(|host| {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
let name = if parts.len() >= 2 { parts[0] } else { host };
|
||||
let mut c = name.chars();
|
||||
c.next().map(|f| f.to_uppercase().to_string() + c.as_str())
|
||||
});
|
||||
|
||||
Ok(OidcTestResultDto {
|
||||
success: true,
|
||||
message: "OIDC provider is reachable and returned a valid discovery document.".into(),
|
||||
issuer: disc.issuer,
|
||||
authorization_endpoint: disc.authorization_endpoint,
|
||||
token_endpoint: disc.token_endpoint,
|
||||
userinfo_endpoint: disc.userinfo_endpoint,
|
||||
provider_name_suggestion: suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use crate::domain::entities::user::{User, UserRole};
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort, OidcServicePort, OidcIdClaims};
|
||||
@@ -8,14 +9,19 @@ use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::common::config::OidcConfig;
|
||||
|
||||
/// Interior state for OIDC — protected by RwLock for hot-reload.
|
||||
struct OidcState {
|
||||
service: Option<Arc<dyn OidcServicePort>>,
|
||||
config: Option<OidcConfig>,
|
||||
}
|
||||
|
||||
pub struct AuthApplicationService {
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
folder_service: Option<Arc<dyn FolderUseCase>>,
|
||||
oidc_service: Option<Arc<dyn OidcServicePort>>,
|
||||
oidc_config: Option<OidcConfig>,
|
||||
oidc: RwLock<OidcState>,
|
||||
}
|
||||
|
||||
impl AuthApplicationService {
|
||||
@@ -31,8 +37,7 @@ impl AuthApplicationService {
|
||||
password_hasher,
|
||||
token_service,
|
||||
folder_service: None,
|
||||
oidc_service: None,
|
||||
oidc_config: None,
|
||||
oidc: RwLock::new(OidcState { service: None, config: None }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,30 +48,51 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
/// Configura el servicio OIDC
|
||||
pub fn with_oidc(mut self, oidc_service: Arc<dyn OidcServicePort>, oidc_config: OidcConfig) -> Self {
|
||||
self.oidc_service = Some(oidc_service);
|
||||
self.oidc_config = Some(oidc_config);
|
||||
pub fn with_oidc(self, oidc_service: Arc<dyn OidcServicePort>, oidc_config: OidcConfig) -> Self {
|
||||
{
|
||||
let mut state = self.oidc.write().unwrap();
|
||||
state.service = Some(oidc_service);
|
||||
state.config = Some(oidc_config);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Hot-reload OIDC configuration at runtime (called from admin settings service)
|
||||
pub fn reload_oidc(&self, oidc_service: Arc<dyn OidcServicePort>, oidc_config: OidcConfig) {
|
||||
let mut state = self.oidc.write().unwrap();
|
||||
state.service = Some(oidc_service);
|
||||
state.config = Some(oidc_config);
|
||||
}
|
||||
|
||||
/// Disable OIDC at runtime (called from admin settings service)
|
||||
pub fn disable_oidc(&self) {
|
||||
let mut state = self.oidc.write().unwrap();
|
||||
state.service = None;
|
||||
state.config = None;
|
||||
}
|
||||
|
||||
/// Returns whether OIDC is configured and enabled
|
||||
pub fn oidc_enabled(&self) -> bool {
|
||||
self.oidc_service.is_some() && self.oidc_config.as_ref().map_or(false, |c| c.enabled)
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.service.is_some() && state.config.as_ref().map_or(false, |c| c.enabled)
|
||||
}
|
||||
|
||||
/// Returns whether password login is disabled (OIDC-only mode)
|
||||
pub fn password_login_disabled(&self) -> bool {
|
||||
self.oidc_config.as_ref().map_or(false, |c| c.disable_password_login)
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.config.as_ref().map_or(false, |c| c.disable_password_login)
|
||||
}
|
||||
|
||||
/// Returns the OIDC config if available
|
||||
pub fn oidc_config(&self) -> Option<&OidcConfig> {
|
||||
self.oidc_config.as_ref()
|
||||
/// Returns a clone of the OIDC config if available
|
||||
pub fn oidc_config(&self) -> Option<OidcConfig> {
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.config.clone()
|
||||
}
|
||||
|
||||
/// Returns the OIDC service if available
|
||||
pub fn oidc_service(&self) -> Option<&Arc<dyn OidcServicePort>> {
|
||||
self.oidc_service.as_ref()
|
||||
/// Returns an Arc clone of the OIDC service if available
|
||||
pub fn oidc_service(&self) -> Option<Arc<dyn OidcServicePort>> {
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.service.clone()
|
||||
}
|
||||
|
||||
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
|
||||
@@ -559,7 +585,7 @@ impl AuthApplicationService {
|
||||
/// Generate the OIDC authorization URL for redirecting the user to the IdP.
|
||||
/// The `state` parameter is a signed JWT to prevent CSRF.
|
||||
pub fn oidc_authorize_url(&self, state: &str) -> Result<String, DomainError> {
|
||||
let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new(
|
||||
let oidc = self.oidc_service().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC service not configured",
|
||||
))?;
|
||||
oidc.get_authorize_url(state)
|
||||
@@ -578,12 +604,17 @@ impl AuthApplicationService {
|
||||
/// Handle the OIDC callback: exchange code, validate ID token,
|
||||
/// find or create user (JIT provisioning), and issue internal tokens.
|
||||
pub async fn oidc_callback(&self, code: &str) -> Result<AuthResponseDto, DomainError> {
|
||||
let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC service not configured",
|
||||
))?;
|
||||
let oidc_config = self.oidc_config.as_ref().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC config not available",
|
||||
))?;
|
||||
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
|
||||
let (oidc, oidc_config) = {
|
||||
let state = self.oidc.read().unwrap();
|
||||
let svc = state.service.clone().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC service not configured",
|
||||
))?;
|
||||
let cfg = state.config.clone().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC config not available",
|
||||
))?;
|
||||
(svc, cfg)
|
||||
};
|
||||
|
||||
// 1. Exchange authorization code for tokens
|
||||
let token_set = oidc.exchange_code(code).await?;
|
||||
@@ -649,7 +680,7 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
// Determine role from OIDC groups
|
||||
let role = self.map_oidc_role(&claims.groups, oidc_config);
|
||||
let role = self.map_oidc_role(&claims.groups, &oidc_config);
|
||||
|
||||
let quota = if role == UserRole::Admin {
|
||||
107374182400 // 100GB
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin_settings_service;
|
||||
pub mod auth_application_service;
|
||||
pub mod batch_operations;
|
||||
pub mod calendar_service;
|
||||
|
||||
@@ -309,6 +309,32 @@ impl Default for OidcConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl OidcConfig {
|
||||
/// Load OIDC configuration from environment variables only
|
||||
pub fn from_env() -> Self {
|
||||
use std::env;
|
||||
let mut cfg = Self::default();
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") {
|
||||
cfg.enabled = v.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { cfg.issuer_url = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { cfg.client_id = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { cfg.client_secret = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { cfg.redirect_uri = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { cfg.scopes = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { cfg.frontend_url = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") {
|
||||
cfg.auto_provision = v.parse::<bool>().unwrap_or(true);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { cfg.admin_groups = v; }
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") {
|
||||
cfg.disable_password_login = v.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { cfg.provider_name = v; }
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuración de funcionalidades (feature flags)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeaturesConfig {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::application::services::admin_settings_service::AdminSettingsService;
|
||||
|
||||
use crate::infrastructure::services::path_service::PathService;
|
||||
use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository;
|
||||
@@ -566,6 +567,7 @@ impl AppServiceFactory {
|
||||
applications: apps,
|
||||
db_pool: db_pool.clone(),
|
||||
auth_service: auth_services,
|
||||
admin_settings_service: None,
|
||||
trash_service,
|
||||
share_service,
|
||||
favorites_service,
|
||||
@@ -578,6 +580,47 @@ impl AppServiceFactory {
|
||||
contact_use_case: None,
|
||||
};
|
||||
|
||||
// 10b. Wire admin settings service when auth + DB are available
|
||||
if let (Some(ref auth_svc), Some(ref pool)) = (&app_state.auth_service, &db_pool) {
|
||||
let settings_repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone())
|
||||
);
|
||||
let server_base_url = std::env::var("OXICLOUD_BASE_URL").unwrap_or_else(|_| {
|
||||
format!("http://{}:{}", self.config.server_host, self.config.server_port)
|
||||
});
|
||||
|
||||
// Load OIDC config from env vars (the snapshot from startup)
|
||||
let env_oidc = crate::common::config::OidcConfig::from_env();
|
||||
|
||||
let admin_svc = Arc::new(AdminSettingsService::new(
|
||||
settings_repo.clone(),
|
||||
env_oidc,
|
||||
auth_svc.auth_application_service.clone(),
|
||||
server_base_url,
|
||||
));
|
||||
|
||||
// Hot-reload OIDC from DB settings if configured
|
||||
match admin_svc.load_effective_oidc_config().await {
|
||||
Ok(eff) if eff.enabled && !eff.issuer_url.is_empty()
|
||||
&& !eff.client_id.is_empty() && !eff.client_secret.is_empty() =>
|
||||
{
|
||||
let oidc_svc = Arc::new(
|
||||
crate::infrastructure::services::oidc_service::OidcService::new(eff.clone())
|
||||
);
|
||||
auth_svc.auth_application_service.reload_oidc(oidc_svc, eff);
|
||||
tracing::info!("OIDC config loaded from admin settings (database)");
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::info!("No active OIDC config in admin settings — using env vars or defaults");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load OIDC settings from database (table may not exist yet): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
app_state.admin_settings_service = Some(admin_svc);
|
||||
}
|
||||
|
||||
// 11. Wire CalDAV/CardDAV services when database is available
|
||||
if let Some(ref pool) = db_pool {
|
||||
// CalDAV
|
||||
@@ -689,6 +732,7 @@ pub struct AppState {
|
||||
pub applications: ApplicationServices,
|
||||
pub db_pool: Option<Arc<PgPool>>,
|
||||
pub auth_service: Option<AuthServices>,
|
||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||
pub trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
|
||||
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
|
||||
@@ -827,6 +871,7 @@ impl Default for AppState {
|
||||
applications: application_services,
|
||||
db_pool: None,
|
||||
auth_service: None,
|
||||
admin_settings_service: None,
|
||||
trash_service: None,
|
||||
share_service: None,
|
||||
favorites_service: None,
|
||||
@@ -853,6 +898,7 @@ impl AppState {
|
||||
applications,
|
||||
db_pool: None,
|
||||
auth_service: None,
|
||||
admin_settings_service: None,
|
||||
trash_service: None,
|
||||
share_service: None,
|
||||
favorites_service: None,
|
||||
|
||||
@@ -7,4 +7,5 @@ pub mod folder_repository;
|
||||
pub mod session_repository;
|
||||
pub mod share_repository;
|
||||
pub mod trash_repository;
|
||||
pub mod settings_repository;
|
||||
pub mod user_repository;
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::collections::HashMap;
|
||||
use async_trait::async_trait;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Repository for platform settings stored in the database.
|
||||
/// Settings are key-value pairs organized by category (e.g., "oidc", "general").
|
||||
#[async_trait]
|
||||
pub trait SettingsRepository: Send + Sync + 'static {
|
||||
/// Get a single setting value by key
|
||||
async fn get(&self, key: &str) -> Result<Option<String>, DomainError>;
|
||||
|
||||
/// Get all settings for a given category
|
||||
async fn get_by_category(&self, category: &str) -> Result<HashMap<String, String>, DomainError>;
|
||||
|
||||
/// Set a setting value (upsert)
|
||||
async fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
category: &str,
|
||||
is_secret: bool,
|
||||
updated_by: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Delete a setting by key
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
|
||||
mod favorites_pg_repository;
|
||||
mod recent_items_pg_repository;
|
||||
mod session_pg_repository;
|
||||
mod settings_pg_repository;
|
||||
mod transaction_utils;
|
||||
mod user_pg_repository;
|
||||
|
||||
@@ -19,4 +20,5 @@ pub use contact_persistence_dto::*;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use recent_items_pg_repository::RecentItemsPgRepository;
|
||||
pub use session_pg_repository::SessionPgRepository;
|
||||
pub use settings_pg_repository::SettingsPgRepository;
|
||||
pub use user_pg_repository::UserPgRepository;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
|
||||
pub struct SettingsPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl SettingsPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsRepository for SettingsPgRepository {
|
||||
async fn get(&self, key: &str) -> Result<Option<String>, DomainError> {
|
||||
let row = sqlx::query_scalar::<_, String>(
|
||||
"SELECT value FROM auth.admin_settings WHERE key = $1"
|
||||
)
|
||||
.bind(key)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "Settings", format!("DB error: {}", e),
|
||||
))?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn get_by_category(&self, category: &str) -> Result<HashMap<String, String>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT key, value FROM auth.admin_settings WHERE category = $1"
|
||||
)
|
||||
.bind(category)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "Settings", format!("DB error: {}", e),
|
||||
))?;
|
||||
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
category: &str,
|
||||
is_secret: bool,
|
||||
updated_by: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = $2, category = $3, is_secret = $4, updated_by = $5, updated_at = NOW()"
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(category)
|
||||
.bind(is_secret)
|
||||
.bind(updated_by)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "Settings", format!("DB error: {}", e),
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM auth.admin_settings WHERE key = $1")
|
||||
.bind(key)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "Settings", format!("DB error: {}", e),
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, put, post},
|
||||
extract::{State, Json},
|
||||
http::{StatusCode, HeaderMap, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::application::dtos::settings_dto::{SaveOidcSettingsDto, TestOidcConnectionDto};
|
||||
use crate::interfaces::errors::AppError;
|
||||
|
||||
/// Admin API routes — all require admin role.
|
||||
pub fn admin_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/settings/oidc", get(get_oidc_settings))
|
||||
.route("/settings/oidc", put(save_oidc_settings))
|
||||
.route("/settings/oidc/test", post(test_oidc_connection))
|
||||
.route("/settings/general", get(get_general_settings))
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, String), AppError> {
|
||||
let auth = state.auth_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Authorization token required"))?;
|
||||
|
||||
let claims = auth.token_service.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(&format!("Invalid token: {}", e)))?;
|
||||
|
||||
if claims.role != "admin" {
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin access required",
|
||||
"Forbidden",
|
||||
));
|
||||
}
|
||||
|
||||
Ok((claims.sub, claims.role))
|
||||
}
|
||||
|
||||
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
|
||||
async fn get_oidc_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state.admin_settings_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Admin settings service not available"))?;
|
||||
|
||||
let settings = svc.get_oidc_settings().await
|
||||
.map_err(|e| AppError::internal_error(&format!("Failed to load settings: {}", e)))?;
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
/// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload
|
||||
async fn save_oidc_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<SaveOidcSettingsDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (user_id, _) = admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state.admin_settings_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Admin settings service not available"))?;
|
||||
|
||||
svc.save_oidc_settings(dto, &user_id).await
|
||||
.map_err(|e| AppError::internal_error(&format!("Failed to save settings: {}", e)))?;
|
||||
|
||||
Ok((StatusCode::OK, Json(serde_json::json!({
|
||||
"message": "OIDC settings saved and applied successfully"
|
||||
}))))
|
||||
}
|
||||
|
||||
/// POST /api/admin/settings/oidc/test — test OIDC discovery
|
||||
async fn test_oidc_connection(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<TestOidcConnectionDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state.admin_settings_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Admin settings service not available"))?;
|
||||
|
||||
let result = svc.test_oidc_connection(dto).await
|
||||
.map_err(|e| AppError::internal_error(&format!("Connection test failed: {}", e)))?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/admin/settings/general — system overview
|
||||
async fn get_general_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let auth = state.auth_service.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
|
||||
let user_count = auth.auth_application_service.count_all_users().await.unwrap_or(0);
|
||||
let oidc_configured = auth.auth_application_service.oidc_enabled();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"server_version": env!("CARGO_PKG_VERSION"),
|
||||
"auth_enabled": true,
|
||||
"total_users": user_count,
|
||||
"oidc_configured": oidc_configured,
|
||||
})))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin_handler;
|
||||
pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
pub mod i18n_handler;
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::interfaces::api::handlers::file_handler::FileHandler;
|
||||
use crate::interfaces::api::handlers::i18n_handler::I18nHandler;
|
||||
use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler;
|
||||
use crate::interfaces::api::handlers::trash_handler;
|
||||
use crate::interfaces::api::handlers::admin_handler;
|
||||
use crate::interfaces::api::handlers::batch_handler::{
|
||||
self, BatchHandlerState
|
||||
};
|
||||
@@ -282,6 +283,11 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
|
||||
// NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav)
|
||||
// in main.rs for protocol compliance, NOT under /api.
|
||||
|
||||
// Admin settings routes (protected by admin_guard inside the handler)
|
||||
let admin_router = admin_handler::admin_routes()
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/admin", admin_router);
|
||||
|
||||
router
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OxiCloud — Admin Settings</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f7fa;color:#1a1a2e;line-height:1.6}
|
||||
.container{max-width:720px;margin:40px auto;padding:0 20px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:32px}
|
||||
header h1{font-size:1.5rem;color:#1a1a2e}
|
||||
header a{color:#3b82f6;text-decoration:none;font-size:.9rem}
|
||||
header a:hover{text-decoration:underline}
|
||||
.card{background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:28px;margin-bottom:24px}
|
||||
.card h2{font-size:1.15rem;margin-bottom:20px;color:#1a1a2e;display:flex;align-items:center;gap:8px}
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-group label{display:block;font-size:.85rem;font-weight:600;margin-bottom:4px;color:#374151}
|
||||
.form-group input[type="text"],.form-group input[type="password"],.form-group input[type="url"]{
|
||||
width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:.9rem;transition:border .2s}
|
||||
.form-group input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
|
||||
.form-group small{color:#6b7280;font-size:.78rem;display:block;margin-top:2px}
|
||||
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0}
|
||||
.toggle-row label{font-size:.9rem;font-weight:500}
|
||||
.switch{position:relative;width:44px;height:24px;flex-shrink:0}
|
||||
.switch input{opacity:0;width:0;height:0}
|
||||
.slider{position:absolute;cursor:pointer;inset:0;background:#d1d5db;border-radius:24px;transition:.3s}
|
||||
.slider:before{content:"";position:absolute;height:18px;width:18px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.3s}
|
||||
.switch input:checked+.slider{background:#3b82f6}
|
||||
.switch input:checked+.slider:before{transform:translateX(20px)}
|
||||
.readonly-field{display:flex;align-items:center;gap:8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:8px 12px;font-family:monospace;font-size:.85rem;word-break:break-all}
|
||||
.readonly-field button{flex-shrink:0;padding:4px 10px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:.78rem}
|
||||
.readonly-field button:hover{background:#f3f4f6}
|
||||
details{margin-top:16px;border-top:1px solid #e5e7eb;padding-top:12px}
|
||||
details summary{cursor:pointer;font-weight:600;font-size:.9rem;color:#6b7280;padding:4px 0;user-select:none}
|
||||
details[open] summary{margin-bottom:12px}
|
||||
.actions{display:flex;gap:12px;margin-top:24px;justify-content:flex-end}
|
||||
.btn{padding:10px 20px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;transition:all .2s}
|
||||
.btn-primary{background:#3b82f6;color:#fff}
|
||||
.btn-primary:hover{background:#2563eb}
|
||||
.btn-secondary{background:#fff;color:#374151;border:1px solid #d1d5db}
|
||||
.btn-secondary:hover{background:#f9fafb}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.alert{padding:12px 16px;border-radius:8px;font-size:.85rem;margin-top:16px;display:none}
|
||||
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0;display:block}
|
||||
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;display:block}
|
||||
.alert-info{background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe;display:block}
|
||||
.badge{display:inline-block;font-size:.7rem;padding:2px 6px;border-radius:4px;background:#fef3c7;color:#92400e;margin-left:6px;font-weight:600}
|
||||
.discovery-result{margin:12px 0;padding:12px;border-radius:8px;font-size:.82rem}
|
||||
.discovery-result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
|
||||
.discovery-result.fail{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
||||
.discovery-result dt{font-weight:600;margin-top:6px}
|
||||
.discovery-result dd{margin-left:0;word-break:break-all}
|
||||
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.info-item{padding:12px;background:#f9fafb;border-radius:8px}
|
||||
.info-item .label{font-size:.75rem;color:#6b7280;text-transform:uppercase;letter-spacing:.05em}
|
||||
.info-item .value{font-size:1.1rem;font-weight:600;margin-top:4px}
|
||||
.warning{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;font-size:.82rem;color:#92400e;margin-top:8px;display:flex;align-items:baseline;gap:6px}
|
||||
#access-denied{display:none;text-align:center;padding:60px 20px}
|
||||
#access-denied h2{color:#991b1b;margin-bottom:8px}
|
||||
#loading{text-align:center;padding:60px;color:#6b7280}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>⚙️ Admin Settings</h1>
|
||||
<a href="/">← Back to OxiCloud</a>
|
||||
</header>
|
||||
|
||||
<div id="loading">Loading…</div>
|
||||
<div id="access-denied"><h2>Access Denied</h2><p>Administrator privileges required.</p><a href="/login.html">Sign in</a></div>
|
||||
|
||||
<div id="main-content" style="display:none">
|
||||
<!-- OIDC / SSO Settings -->
|
||||
<div class="card">
|
||||
<h2>🔐 Single Sign-On (OIDC / SSO)</h2>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label>Enable SSO Authentication</label>
|
||||
<label class="switch"><input type="checkbox" id="oidc-enabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
<div id="oidc-form" style="display:none">
|
||||
<div class="form-group">
|
||||
<label>Provider Name <span id="badge-provider_name"></span></label>
|
||||
<input type="text" id="provider-name" placeholder="e.g., Authentik, Keycloak">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Issuer URL <span id="badge-issuer_url"></span></label>
|
||||
<input type="url" id="issuer-url" placeholder="https://auth.example.com/application/o/oxicloud/">
|
||||
<small>The OpenID Connect issuer URL of your identity provider</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:12px">
|
||||
<button class="btn btn-secondary" id="discover-btn" onclick="testConnection()">🔍 Auto-discover</button>
|
||||
</div>
|
||||
<div id="discovery-result"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Client ID <span id="badge-client_id"></span></label>
|
||||
<input type="text" id="client-id" placeholder="oxicloud">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Client Secret <span id="badge-client_secret"></span></label>
|
||||
<input type="password" id="client-secret" placeholder="Leave empty to keep current value">
|
||||
<small id="secret-hint" style="display:none">✓ A client secret is already configured</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Callback URL <small>(copy this to your IdP configuration)</small></label>
|
||||
<div class="readonly-field">
|
||||
<span id="callback-url">—</span>
|
||||
<button onclick="copyCallback()">📋 Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Advanced Settings</summary>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Scopes <span id="badge-scopes"></span></label>
|
||||
<input type="text" id="scopes" placeholder="openid profile email">
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label>Auto-provision users on first login</label>
|
||||
<label class="switch"><input type="checkbox" id="auto-provision" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Admin Groups <span id="badge-admin_groups"></span></label>
|
||||
<input type="text" id="admin-groups" placeholder="e.g., oxicloud-admins">
|
||||
<small>Comma-separated OIDC group names that map to admin role</small>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label>Disable password login (OIDC only)</label>
|
||||
<label class="switch"><input type="checkbox" id="disable-password"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="warning" id="password-warning" style="display:none">
|
||||
⚠️ Enabling this will prevent ALL password-based logins. Make sure OIDC is working first!
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="testConnection()">🧪 Test Connection</button>
|
||||
<button class="btn btn-primary" id="save-btn" onclick="saveSettings()">💾 Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="status-message" class="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="card">
|
||||
<h2>📊 System Information</h2>
|
||||
<div class="info-grid" id="general-info">
|
||||
<div class="info-item"><div class="label">Version</div><div class="value" id="info-version">—</div></div>
|
||||
<div class="info-item"><div class="label">Users</div><div class="value" id="info-users">—</div></div>
|
||||
<div class="info-item"><div class="label">Auth</div><div class="value" id="info-auth">—</div></div>
|
||||
<div class="info-item"><div class="label">OIDC</div><div class="value" id="info-oidc">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/api';
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
||||
|
||||
// Toggle OIDC form visibility
|
||||
document.getElementById('oidc-enabled').addEventListener('change', function() {
|
||||
document.getElementById('oidc-form').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Show password warning
|
||||
document.getElementById('disable-password').addEventListener('change', function() {
|
||||
document.getElementById('password-warning').style.display = this.checked ? 'flex' : 'none';
|
||||
});
|
||||
|
||||
function headers() {
|
||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
function showStatus(msg, type) {
|
||||
const el = document.getElementById('status-message');
|
||||
el.textContent = msg;
|
||||
el.className = 'alert alert-' + type;
|
||||
}
|
||||
|
||||
function copyCallback() {
|
||||
const text = document.getElementById('callback-url').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.querySelector('.readonly-field button');
|
||||
btn.textContent = '✓ Copied!';
|
||||
setTimeout(() => btn.textContent = '📋 Copy', 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!token) { showAccessDenied(); return; }
|
||||
|
||||
try {
|
||||
// Verify admin access
|
||||
const me = await fetch(API + '/auth/me', { headers: headers() });
|
||||
if (!me.ok) { showAccessDenied(); return; }
|
||||
const user = await me.json();
|
||||
if (user.role !== 'admin') { showAccessDenied(); return; }
|
||||
|
||||
// Load OIDC settings
|
||||
const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers() });
|
||||
if (oidcResp.ok) {
|
||||
const s = await oidcResp.json();
|
||||
document.getElementById('oidc-enabled').checked = s.enabled;
|
||||
document.getElementById('oidc-form').style.display = s.enabled ? 'block' : 'none';
|
||||
document.getElementById('provider-name').value = s.provider_name || '';
|
||||
document.getElementById('issuer-url').value = s.issuer_url || '';
|
||||
document.getElementById('client-id').value = s.client_id || '';
|
||||
document.getElementById('scopes').value = s.scopes || 'openid profile email';
|
||||
document.getElementById('auto-provision').checked = s.auto_provision;
|
||||
document.getElementById('admin-groups').value = s.admin_groups || '';
|
||||
document.getElementById('disable-password').checked = s.disable_password_login;
|
||||
document.getElementById('password-warning').style.display = s.disable_password_login ? 'flex' : 'none';
|
||||
document.getElementById('callback-url').textContent = s.callback_url;
|
||||
if (s.client_secret_set) document.getElementById('secret-hint').style.display = 'block';
|
||||
|
||||
// Show env override badges
|
||||
(s.env_overrides || []).forEach(field => {
|
||||
const badge = document.getElementById('badge-' + field);
|
||||
if (badge) { badge.innerHTML = '<span class="badge">ENV</span>'; }
|
||||
});
|
||||
}
|
||||
|
||||
// Load general info
|
||||
const genResp = await fetch(API + '/admin/settings/general', { headers: headers() });
|
||||
if (genResp.ok) {
|
||||
const g = await genResp.json();
|
||||
document.getElementById('info-version').textContent = g.server_version;
|
||||
document.getElementById('info-users').textContent = g.total_users;
|
||||
document.getElementById('info-auth').textContent = g.auth_enabled ? 'Enabled' : 'Disabled';
|
||||
document.getElementById('info-oidc').textContent = g.oidc_configured ? 'Active' : 'Not configured';
|
||||
}
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('main-content').style.display = 'block';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
function showAccessDenied() {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('access-denied').style.display = 'block';
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const url = document.getElementById('issuer-url').value.trim();
|
||||
if (!url) { showStatus('Please enter an Issuer URL first', 'error'); return; }
|
||||
|
||||
const btn = document.getElementById('discover-btn');
|
||||
btn.disabled = true; btn.textContent = '⏳ Testing…';
|
||||
const resultDiv = document.getElementById('discovery-result');
|
||||
resultDiv.innerHTML = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/settings/oidc/test', {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify({ issuer_url: url })
|
||||
});
|
||||
const r = await resp.json();
|
||||
|
||||
if (r.success) {
|
||||
resultDiv.innerHTML = '<div class="discovery-result ok">' +
|
||||
'<strong>✓ ' + r.message + '</strong>' +
|
||||
'<dl><dt>Issuer</dt><dd>' + (r.issuer||'—') + '</dd>' +
|
||||
'<dt>Auth Endpoint</dt><dd>' + (r.authorization_endpoint||'—') + '</dd>' +
|
||||
'<dt>Token Endpoint</dt><dd>' + (r.token_endpoint||'—') + '</dd></dl></div>';
|
||||
// Auto-fill provider name if empty
|
||||
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) {
|
||||
document.getElementById('provider-name').value = r.provider_name_suggestion;
|
||||
}
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="discovery-result fail"><strong>✗ ' + r.message + '</strong></div>';
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = '<div class="discovery-result fail"><strong>✗ Network error: ' + e.message + '</strong></div>';
|
||||
}
|
||||
btn.disabled = false; btn.textContent = '🔍 Auto-discover';
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const btn = document.getElementById('save-btn');
|
||||
btn.disabled = true; btn.textContent = '⏳ Saving…';
|
||||
|
||||
const body = {
|
||||
enabled: document.getElementById('oidc-enabled').checked,
|
||||
issuer_url: document.getElementById('issuer-url').value.trim(),
|
||||
client_id: document.getElementById('client-id').value.trim(),
|
||||
client_secret: document.getElementById('client-secret').value || null,
|
||||
scopes: document.getElementById('scopes').value.trim() || null,
|
||||
auto_provision: document.getElementById('auto-provision').checked,
|
||||
admin_groups: document.getElementById('admin-groups').value.trim() || null,
|
||||
disable_password_login: document.getElementById('disable-password').checked,
|
||||
provider_name: document.getElementById('provider-name').value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/settings/oidc', {
|
||||
method: 'PUT', headers: headers(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (resp.ok) {
|
||||
showStatus('Settings saved and applied — OIDC is now ' + (body.enabled ? 'active' : 'disabled') + '.', 'success');
|
||||
if (body.client_secret) document.getElementById('secret-hint').style.display = 'block';
|
||||
// Refresh OIDC status
|
||||
const genResp = await fetch(API + '/admin/settings/general', { headers: headers() });
|
||||
if (genResp.ok) { const g = await genResp.json(); document.getElementById('info-oidc').textContent = g.oidc_configured ? 'Active' : 'Not configured'; }
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showStatus('Error: ' + (err.message || resp.statusText), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Network error: ' + e.message, 'error');
|
||||
}
|
||||
btn.disabled = false; btn.textContent = '💾 Save Settings';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user