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:
Dionisio
2026-02-11 00:15:26 +01:00
parent 8ef62109a3
commit f60c0df9f9
16 changed files with 1048 additions and 23 deletions
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod admin_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod i18n_handler;
+6
View File
@@ -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())