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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user